package services

// ProjectService handles CRUD on paliad.projects — the hierarchical
// project tree that replaced the flat paliad.akten model in migration 018.
//
// Visibility (design v2, adjusted 2026-04-20): team-based only.
//   A user can see a Project iff
//     - user is admin, or
//     - user is a direct member of the Project's team, or
//     - user is a member of any ancestor Project's team (inherited via path).
//
// Office is no longer a visibility gate. Cases associate with lead partners,
// not offices (see paliad.project_teams role='lead').
//
// The canonical predicate lives in SQL (paliad.can_see_project) and is
// enforced by RLS policies. This service re-implements the same predicate
// at the application layer so the service-role DB connection (without an
// auth.uid() JWT) still gates correctly.

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

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

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

// Sentinel errors.
var (
	// ErrNotVisible indicates the Project exists but the user has no
	// visibility. Handlers must map to 404 (never leak existence).
	ErrNotVisible = errors.New("project not visible")
	// ErrForbidden indicates the user is authenticated but lacks the role
	// required for the operation (e.g., associate trying to delete).
	ErrForbidden = errors.New("forbidden")
	// ErrInvalidInput signals a bad request (empty required field etc.).
	ErrInvalidInput = errors.New("invalid input")
	// ErrLastProjectAdmin guards demoting / removing the last remaining
	// effective_project_admin from a project + its ancestor chain. t-paliad-223
	// invariant: every project should keep at least one admin somewhere in
	// its ancestor chain so a non-global-admin can still manage the team.
	// Handlers map to 409 Conflict.
	ErrLastProjectAdmin = errors.New("cannot remove last project admin from project + ancestors")
	// ErrInvalidProceedingTypeCategory signals that the caller supplied
	// a proceeding_type_id pointing at a non-fristenrechner-category row.
	// Phase 3 Slice 5 soft-merge (t-paliad-186, design §3.F): only
	// fristenrechner-category codes may bind to a project. Handlers
	// surface this as a 400 with a bilingual friendly message; the
	// matching DB trigger (mig 088) is the defence-in-depth backstop.
	ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row")
	// ErrInvalidProceedingTypeKind signals that the caller supplied a
	// proceeding_type_id pointing at a non-primary row — i.e. a
	// phase/side_action/meta row, or an inactive row. Mig 153
	// (t-paliad-325, design §1) carved the taxonomy so only
	// kind='proceeding' AND is_active=true rows may bind to a
	// project. Handlers surface this as a 400; the matching DB
	// trigger (mig 153) is the defence-in-depth backstop.
	ErrInvalidProceedingTypeKind = errors.New("proceeding_type_id must reference an active kind='proceeding' proceeding_types row")
)

// ProjectType values enumerated on the projects.type CHECK constraint.
// 'other' (mig 110, m/paliad#51) is the explicit "unclassified" bucket —
// previously this appeared as a synthetic "Empty" option in the type
// filter; the chip now offers it as a real selectable type.
const (
	ProjectTypeClient     = "client"
	ProjectTypeLitigation = "litigation"
	ProjectTypePatent     = "patent"
	ProjectTypeCase       = "case"
	ProjectTypeProject    = "project"
	ProjectTypeOther      = "other"
)

// Legacy ProjectRole values that used to live on paliad.project_teams.role.
//
// DEPRECATED (t-paliad-148): the role column has been split into
// users.profession (firm-tier) + project_teams.responsibility (per-
// project). New code should use ProfessionPartner / ResponsibilityLead /
// etc. from approval_levels.go. These constants stay defined for one
// release because the deprecated shadow column is still written by
// AddMember (mapped via legacyRoleFromResponsibility); follow-up
// migration 058 retires the column and these constants can be deleted
// then.
const (
	RoleLead         = "lead"
	RoleAssociate    = "associate"
	RolePA           = "pa"
	RoleOfCounsel    = "of_counsel"
	RoleLocalCounsel = "local_counsel"
	RoleExpert       = "expert"
	RoleObserver     = "observer"
)

// ProjectService reads and writes paliad.projects + paliad.project_events.
type ProjectService struct {
	db    *sqlx.DB
	users *UserService
}

// NewProjectService wires the service.
func NewProjectService(db *sqlx.DB, users *UserService) *ProjectService {
	return &ProjectService{db: db, users: users}
}

// Users exposes the shared user service for downstream services that gate
// through ProjectService (DeadlineService, AppointmentService, NoteService, …).
func (s *ProjectService) Users() *UserService { return s.users }

// DB exposes the underlying connection pool for services that need to issue
// custom queries (dashboard aggregates, caldav sync). Read-only usage.
func (s *ProjectService) DB() *sqlx.DB { return s.db }

const projectColumns = `id, type, parent_id, path, title, reference, description, status,
	created_by, industry, country, billing_reference, client_number, matter_number,
	netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
	proceeding_type_id, our_side, opponent_code, counterclaim_of, instance_level, metadata, ai_summary,
	created_at, updated_at`

// CreateProjectInput is the payload for Create.
type CreateProjectInput struct {
	Type        string     `json:"type"`
	ParentID    *uuid.UUID `json:"parent_id,omitempty"`
	Title       string     `json:"title"`
	Reference   *string    `json:"reference,omitempty"`
	Description *string    `json:"description,omitempty"`
	Status      string     `json:"status,omitempty"` // default "active"

	// Type-specific; service applies only the subset matching Type.
	Industry         *string    `json:"industry,omitempty"`
	Country          *string    `json:"country,omitempty"`
	BillingReference *string    `json:"billing_reference,omitempty"`
	ClientNumber     *string    `json:"client_number,omitempty"`
	MatterNumber     *string    `json:"matter_number,omitempty"`
	NetDocumentsURL  *string    `json:"netdocuments_url,omitempty"`
	PatentNumber     *string    `json:"patent_number,omitempty"`
	FilingDate       *time.Time `json:"filing_date,omitempty"`
	GrantDate        *time.Time `json:"grant_date,omitempty"`
	Court            *string    `json:"court,omitempty"`
	CaseNumber       *string    `json:"case_number,omitempty"`
	ProceedingTypeID *int       `json:"proceeding_type_id,omitempty"`
	OurSide          *string    `json:"our_side,omitempty"`
	// OpponentCode is the litigation-only short slug used as the middle
	// segment when BuildProjectCode assembles a project code from the
	// ancestor tree (t-paliad-222 / m/paliad#50). Empty / nil → segment
	// skipped. Only meaningful on type='litigation' rows; the form
	// hides the field elsewhere and the DB CHECK rejects it.
	OpponentCode *string `json:"opponent_code,omitempty"`
	// InstanceLevel is the procedural instance the project sits at:
	// 'first' (default once the picker UI lands) | 'appeal' | 'cassation'.
	// NULL = unset. Phase 3 Slice 8 (t-paliad-189, design §7) — the
	// SmartTimeline + calculator combine this with proceeding_code +
	// jurisdiction to pick the effective rule corpus (de.inf.lg + appeal →
	// de.inf.olg, etc.). Validated against the mig 080 CHECK on the
	// column; service surfaces ErrInvalidInput on a bad value.
	InstanceLevel *string `json:"instance_level,omitempty"`

	// CounterclaimOf marks this project as a CCR sub-project filed
	// against the referenced parent project (t-paliad-174 Slice 3).
	// Set by ProjectService.CreateCounterclaim — direct callers of
	// Create rarely need it. The two-level-CCR rejection trigger
	// (migration 077) will reject malformed shapes regardless.
	CounterclaimOf *uuid.UUID `json:"counterclaim_of,omitempty"`
}

// UpdateProjectInput is the partial-update payload.
type UpdateProjectInput struct {
	Type        *string `json:"type,omitempty"`
	Title       *string `json:"title,omitempty"`
	Reference   *string `json:"reference,omitempty"`
	Description *string `json:"description,omitempty"`
	Status      *string `json:"status,omitempty"`
	ParentID    *uuid.UUID `json:"parent_id,omitempty"` // reparent; server recomputes path

	Industry         *string    `json:"industry,omitempty"`
	Country          *string    `json:"country,omitempty"`
	BillingReference *string    `json:"billing_reference,omitempty"`
	ClientNumber     *string    `json:"client_number,omitempty"`
	MatterNumber     *string    `json:"matter_number,omitempty"`
	NetDocumentsURL  *string    `json:"netdocuments_url,omitempty"`
	PatentNumber     *string    `json:"patent_number,omitempty"`
	FilingDate       *time.Time `json:"filing_date,omitempty"`
	GrantDate        *time.Time `json:"grant_date,omitempty"`
	Court            *string    `json:"court,omitempty"`
	CaseNumber       *string    `json:"case_number,omitempty"`
	ProceedingTypeID *int       `json:"proceeding_type_id,omitempty"`
	OurSide          *string    `json:"our_side,omitempty"`
	// OpponentCode — see CreateProjectInput.OpponentCode. UPDATE path:
	// pointer to "" clears the column (NULL); pointer to a non-empty
	// slug sets it.
	OpponentCode *string `json:"opponent_code,omitempty"`
	// InstanceLevel — see CreateProjectInput.InstanceLevel. UPDATE
	// path: caller passes a pointer to the new value to swap; pass
	// a pointer to "" to clear (NULL the column).
	InstanceLevel *string `json:"instance_level,omitempty"`
}

// ListFilter narrows List results. Zero-value → no filter.
type ProjectFilter struct {
	Type     string     // "", or one of ProjectType* constants
	Status   string     // "", "active", "archived", "closed"
	ParentID *uuid.UUID // filter to direct children of the given parent; use ParentNullOnly for roots
	// ParentNullOnly restricts to root-level rows (parent_id IS NULL).
	// Mutually exclusive with ParentID.
	ParentNullOnly bool
	Search         string // trigram / ILIKE on title, reference, client_number, matter_number
}

// List returns Projects visible to the user, filterable.
func (s *ProjectService) List(ctx context.Context, userID uuid.UUID, f ProjectFilter) ([]models.Project, error) {
	user, err := s.users.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return []models.Project{}, nil
	}

	conds := []string{visibilityPredicate("p")}
	args := map[string]any{
		"user_id": userID,
	}

	if f.Type != "" {
		conds = append(conds, "p.type = :type")
		args["type"] = f.Type
	}
	if f.Status != "" {
		conds = append(conds, "p.status = :status")
		args["status"] = f.Status
	}
	if f.ParentNullOnly {
		conds = append(conds, "p.parent_id IS NULL")
	} else if f.ParentID != nil {
		conds = append(conds, "p.parent_id = :parent_id")
		args["parent_id"] = *f.ParentID
	}
	if s := strings.TrimSpace(f.Search); s != "" {
		conds = append(conds, `(p.title ILIKE :search OR p.reference ILIKE :search
			OR p.client_number ILIKE :search OR p.matter_number ILIKE :search)`)
		args["search"] = "%" + s + "%"
	}

	// Path order keeps every descendant immediately under its ancestor —
	// the same ordering BuildTree produces — so list pickers (events filter,
	// /deadlines/new, /appointments/new, …) can render the project tree as a
	// flat indented list. Recency sort would interleave cousins by last-touch.
	query := `SELECT ` + projectColumns + ` FROM paliad.projects p
	           WHERE ` + strings.Join(conds, " AND ") + `
	           ORDER BY p.path`

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

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

// CanSee reports whether the user has visibility on the Project. 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.
func (s *ProjectService) CanSee(ctx context.Context, userID, id uuid.UUID) (bool, error) {
	var visible bool
	query := `SELECT EXISTS (SELECT 1 FROM paliad.projects p
	          WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2) + `)`
	if err := s.db.GetContext(ctx, &visible, query, id, userID); err != nil {
		return false, fmt.Errorf("check project visibility: %w", err)
	}
	return visible, nil
}

// GetByID returns the Project if the user can see it. Returns (nil, ErrNotVisible)
// when invisible or missing — handlers must not distinguish.
func (s *ProjectService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Project, error) {
	user, err := s.users.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, ErrNotVisible
	}
	var p models.Project
	query := `SELECT ` + projectColumns + ` FROM paliad.projects p
	          WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2)
	err = s.db.GetContext(ctx, &p, query, id, userID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotVisible
	}
	if err != nil {
		return nil, fmt.Errorf("get project: %w", err)
	}
	code, err := BuildProjectCode(ctx, s.db, p.ID)
	if err != nil {
		return nil, err
	}
	p.Code = code
	return &p, nil
}

// ListChildren returns direct children of a Project (visibility-checked on parent).
func (s *ProjectService) ListChildren(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) {
	if _, err := s.GetByID(ctx, userID, id); err != nil {
		return nil, err
	}
	return s.List(ctx, userID, ProjectFilter{ParentID: &id})
}

// ListAncestors walks up the path and returns ancestors from root → parent
// (exclusive of the Project itself). Used for breadcrumbs.
func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) {
	p, err := s.GetByID(ctx, userID, id)
	if err != nil {
		return nil, err
	}
	labels := strings.Split(p.Path, ".")
	if len(labels) <= 1 {
		return []models.Project{}, nil
	}
	// All but last = ancestors.
	ancestorIDs := labels[:len(labels)-1]
	ids := make([]uuid.UUID, 0, len(ancestorIDs))
	for _, s := range ancestorIDs {
		u, err := uuid.Parse(s)
		if err != nil {
			return nil, fmt.Errorf("parse ancestor uuid %q: %w", s, err)
		}
		ids = append(ids, u)
	}
	user, err := s.users.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return []models.Project{}, nil
	}
	// Ancestors are visible whenever the Project is (inheritance works both
	// ways through team membership checks). We still apply the predicate
	// for safety in case path is stale.
	query := `SELECT ` + projectColumns + ` FROM paliad.projects p
	          WHERE p.id = ANY($1::uuid[]) AND ` +
		visibilityPredicatePositional("p", 2)
	// lib/pq doesn't serialise []uuid.UUID natively; render as string array.
	idStrs := make([]string, len(ids))
	for i, u := range ids {
		idStrs[i] = u.String()
	}
	rows := []models.Project{}
	if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID); err != nil {
		return nil, fmt.Errorf("list ancestors: %w", err)
	}
	// Re-order to match path order (root first).
	order := make(map[uuid.UUID]int, len(ids))
	for i, id := range ids {
		order[id] = i
	}
	sortByOrder(rows, order)
	if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
		return nil, err
	}
	return rows, nil
}

// ProjectTreeNode is one node of the nested tree returned by BuildTree.
// It embeds the full Project plus aggregated child nodes and deadline
// counts so the UI can render badges without per-row API calls.
//
// Subtree counts (OpenDeadlinesSubtree / OverdueDeadlinesSubtree) and
// the chip-driven flags (Pinned, InheritedVisibility, MatchKind) are
// only populated when BuildTreeWithOptions is called with the relevant
// options enabled. The legacy BuildTree(ctx, userID) call leaves them
// at zero / empty for back-compat.
type ProjectTreeNode struct {
	models.Project
	Children                []*ProjectTreeNode `json:"children"`
	OpenDeadlines           int                `json:"open_deadlines"`
	OverdueDeadlines        int                `json:"overdue_deadlines"`
	OpenDeadlinesSubtree    int                `json:"open_deadlines_subtree"`
	OverdueDeadlinesSubtree int                `json:"overdue_deadlines_subtree"`
	Pinned                  bool               `json:"pinned"`
	InheritedVisibility     bool               `json:"inherited_visibility"`
	// MatchKind is empty unless a search term is active. Values:
	// "self" (direct match), "ancestor" (on the path to a match),
	// "descendant" (under a match).
	MatchKind string `json:"match_kind,omitempty"`
}

// TreeScope discriminates the chip-driven scope filter for BuildTreeWithOptions.
type TreeScope string

const (
	// ScopeAll returns every visible project (default).
	ScopeAll TreeScope = ""
	// ScopeMine returns directly-staffed projects + their visible ancestors
	// (ancestors flagged InheritedVisibility=true so the UI can render them
	// greyed for context).
	ScopeMine TreeScope = "mine"
	// ScopePinned returns only projects in paliad.user_pinned_projects for
	// the user. Ancestors are NOT auto-included (the chip is "show me the
	// pinned set, period").
	ScopePinned TreeScope = "pinned"
)

// BuildTreeOptions controls BuildTreeWithOptions. Zero value yields the
// legacy BuildTree behaviour (every visible project, per-node counts).
type BuildTreeOptions struct {
	// Scope is the chip-driven scope filter ("Alle" / "Nur meine" / "Angepinnt").
	Scope TreeScope

	// PinnedSet is the user's pinned-project set, populated by the handler
	// from PinService.PinnedSet so BuildTree doesn't need a PinService dep.
	// nil → no pin information attached (Pinned=false on every node).
	PinnedSet map[uuid.UUID]struct{}

	// StatusIn narrows to rows whose status ∈ values. Empty = no narrowing.
	StatusIn []string

	// TypeIn narrows to rows whose type ∈ values. Empty = no narrowing.
	TypeIn []string

	// HasOpenDeadlines, when non-nil, narrows to rows with at least one
	// pending deadline (true) or zero pending deadlines (false). Applied
	// AFTER subtree-count computation so the count itself drives the gate.
	HasOpenDeadlines *bool

	// SearchTerm filters to nodes whose title / reference / clientmatter /
	// ancestor title matches. Match-kind is tagged per node:
	//   "self"       — direct hit
	//   "ancestor"   — on the path to a hit
	//   "descendant" — under a hit (kept for context; same subtree)
	// Empty = no search.
	SearchTerm string

	// IncludeSubtreeCounts populates OpenDeadlinesSubtree +
	// OverdueDeadlinesSubtree. Default: true. Set false for the
	// legacy per-node-only behaviour.
	IncludeSubtreeCounts bool
}

// BuildTree returns the full nested tree of every Project the user can see,
// rooted at all parent_id-IS-NULL projects. Each node carries its open and
// overdue deadline counts (open=pending, overdue=pending&past-due) so the UI
// can render status badges with no extra round-trips. Path-sorted so callers
// get a stable deterministic ordering.
//
// This is a thin shim over BuildTreeWithOptions for back-compat with callers
// that just want every visible project. New callers (the /projects page
// post-t-paliad-149) should use BuildTreeWithOptions directly to access
// chip filters + subtree counts + pinning + search.
func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*ProjectTreeNode, error) {
	return s.BuildTreeWithOptions(ctx, userID, BuildTreeOptions{
		// Default IncludeSubtreeCounts=false: BuildTree's existing callers
		// (the existing tree view) read OpenDeadlines / OverdueDeadlines
		// per-node. Subtree counts are opt-in by the new /projects page
		// via BuildTreeWithOptions.
	})
}

// BuildTreeWithOptions is the chip-aware tree builder. See BuildTreeOptions
// for the knobs. Returns nodes in path order.
func (s *ProjectService) BuildTreeWithOptions(ctx context.Context, userID uuid.UUID, opts BuildTreeOptions) ([]*ProjectTreeNode, error) {
	user, err := s.users.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return []*ProjectTreeNode{}, nil
	}

	// Step 1 — load every visible project (path-ordered). The chip filters
	// (status / type / search) narrow the selection, but Scope=Mine and
	// the subtree-count aggregation BOTH need the full visible set so we
	// can include greyed ancestors and aggregate counts up the tree. The
	// final filtering happens in-memory after the tree is stitched.
	query := `SELECT ` + projectColumns + ` FROM paliad.projects p
	          WHERE ` + visibilityPredicatePositional("p", 1) + `
	          ORDER BY p.path`
	rows := []models.Project{}
	if err := s.db.SelectContext(ctx, &rows, query, userID); err != nil {
		return nil, fmt.Errorf("build tree list: %w", err)
	}
	if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
		return nil, err
	}

	// Step 2 — per-node deadline counts (always; cheap one-shot query).
	type deadlineCount struct {
		ProjectID uuid.UUID `db:"project_id"`
		Open      int       `db:"open"`
		Overdue   int       `db:"overdue"`
	}
	now := time.Now().UTC()
	today := now.Truncate(24 * time.Hour)
	var counts []deadlineCount
	if err := s.db.SelectContext(ctx, &counts, `
		SELECT f.project_id,
		       COUNT(*) FILTER (WHERE f.status = 'pending')                              AS open,
		       COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < $2::date)    AS overdue
		  FROM paliad.deadlines f
		  JOIN paliad.projects p ON p.id = f.project_id
		 WHERE `+visibilityPredicatePositional("p", 1)+`
		 GROUP BY f.project_id`, userID, today); err != nil {
		return nil, fmt.Errorf("build tree deadline counts: %w", err)
	}
	countByID := make(map[uuid.UUID]deadlineCount, len(counts))
	for _, c := range counts {
		countByID[c.ProjectID] = c
	}

	// Step 3 — for ScopeMine, load directly-staffed project IDs. For
	// ScopePinned, the PinnedSet is the source of truth.
	var directlyStaffed map[uuid.UUID]struct{}
	if opts.Scope == ScopeMine {
		var ids []uuid.UUID
		if err := s.db.SelectContext(ctx, &ids, `
			SELECT DISTINCT pt.project_id
			  FROM paliad.project_teams pt
			 WHERE pt.user_id = $1
		`, userID); err != nil {
			return nil, fmt.Errorf("build tree direct staffing: %w", err)
		}
		directlyStaffed = make(map[uuid.UUID]struct{}, len(ids))
		for _, id := range ids {
			directlyStaffed[id] = struct{}{}
		}
	}

	// Step 4 — build node map + stitch the full tree.
	nodes := make(map[uuid.UUID]*ProjectTreeNode, len(rows))
	for i := range rows {
		c := countByID[rows[i].ID]
		n := &ProjectTreeNode{
			Project:          rows[i],
			Children:         []*ProjectTreeNode{},
			OpenDeadlines:    c.Open,
			OverdueDeadlines: c.Overdue,
		}
		if opts.PinnedSet != nil {
			if _, pinned := opts.PinnedSet[rows[i].ID]; pinned {
				n.Pinned = true
			}
		}
		nodes[rows[i].ID] = n
	}

	roots := []*ProjectTreeNode{}
	for _, n := range nodes {
		if n.ParentID == nil {
			roots = append(roots, n)
			continue
		}
		parent, ok := nodes[*n.ParentID]
		if !ok {
			roots = append(roots, n)
			continue
		}
		parent.Children = append(parent.Children, n)
	}
	sortTreeByPath(roots)

	// Step 5 — subtree-aggregated counts (post-order DFS sums each node's
	// own counts plus every descendant's). Cheap (O(N)).
	if opts.IncludeSubtreeCounts {
		var aggregate func(n *ProjectTreeNode) (open, overdue int)
		aggregate = func(n *ProjectTreeNode) (int, int) {
			open := n.OpenDeadlines
			overdue := n.OverdueDeadlines
			for _, c := range n.Children {
				co, cv := aggregate(c)
				open += co
				overdue += cv
			}
			n.OpenDeadlinesSubtree = open
			n.OverdueDeadlinesSubtree = overdue
			return open, overdue
		}
		for _, r := range roots {
			aggregate(r)
		}
	}

	// Step 6 — apply Scope filter. ScopeAll: no-op. ScopeMine: keep
	// directly-staffed nodes + their ancestors (flagged InheritedVisibility
	// for grey-rendering). ScopePinned: keep pinned nodes + their ancestors.
	switch opts.Scope {
	case ScopeMine:
		keep := pathClosure(nodes, directlyStaffed)
		markInherited(nodes, keep, directlyStaffed)
		roots = filterTree(roots, keep)
	case ScopePinned:
		if opts.PinnedSet == nil {
			// No pin set provided → empty tree.
			roots = nil
			break
		}
		keep := pathClosure(nodes, opts.PinnedSet)
		markInherited(nodes, keep, opts.PinnedSet)
		roots = filterTree(roots, keep)
	}

	// Step 7 — chip filters (status / type / has-open-deadlines). We keep
	// nodes that match AND any ancestors needed to root them (so the tree
	// shape is preserved). Directly-narrowing children would orphan them.
	if len(opts.StatusIn) > 0 || len(opts.TypeIn) > 0 || opts.HasOpenDeadlines != nil {
		match := func(n *ProjectTreeNode) bool {
			if len(opts.StatusIn) > 0 && !containsString(opts.StatusIn, n.Status) {
				return false
			}
			if len(opts.TypeIn) > 0 && !containsString(opts.TypeIn, n.Type) {
				return false
			}
			if opts.HasOpenDeadlines != nil {
				openCount := n.OpenDeadlines
				if opts.IncludeSubtreeCounts {
					openCount = n.OpenDeadlinesSubtree
				}
				if *opts.HasOpenDeadlines {
					if openCount == 0 {
						return false
					}
				} else {
					if openCount != 0 {
						return false
					}
				}
			}
			return true
		}
		matched := matchSet(nodes, match)
		keep := pathClosure(nodes, matched)
		// Don't flip InheritedVisibility for chip filters — only Scope=Mine /
		// Scope=Pinned want greyed ancestors. Chips should narrow cleanly.
		roots = filterTree(roots, keep)
	}

	// Step 8 — search. Tags every visible node with match_kind and prunes
	// to the union of {matches ∪ ancestors-of-matches ∪ descendants-of-matches}.
	if term := strings.TrimSpace(opts.SearchTerm); term != "" {
		applySearch(nodes, &roots, term)
	}

	return roots, nil
}

// pathClosure expands a seed set of project IDs into the closure that
// includes every ancestor (via the materialised path) so a filtered tree
// stays connected to its roots. The output set always contains every seed.
func pathClosure(nodes map[uuid.UUID]*ProjectTreeNode, seeds map[uuid.UUID]struct{}) map[uuid.UUID]struct{} {
	keep := make(map[uuid.UUID]struct{}, len(seeds))
	for id := range seeds {
		n, ok := nodes[id]
		if !ok {
			continue
		}
		keep[id] = struct{}{}
		// Walk path labels, skipping empty splits.
		for label := range strings.SplitSeq(n.Path, ".") {
			if label == "" {
				continue
			}
			anc, err := uuid.Parse(label)
			if err != nil {
				continue
			}
			if _, vis := nodes[anc]; vis {
				keep[anc] = struct{}{}
			}
		}
	}
	return keep
}

// markInherited flips InheritedVisibility=true on nodes that are in the
// keep set but NOT in the directly-staffed seed set. The UI greys these
// rows so users understand they're context-only (visibility derives from
// path closure, not direct staffing).
func markInherited(nodes map[uuid.UUID]*ProjectTreeNode, keep, seed map[uuid.UUID]struct{}) {
	for id := range keep {
		if _, direct := seed[id]; direct {
			continue
		}
		if n, ok := nodes[id]; ok {
			n.InheritedVisibility = true
		}
	}
}

// filterTree returns a new root list containing only nodes in keep, with
// each surviving node's Children also pruned to keep. Children of pruned
// nodes are dropped entirely (the path-closure step is what guarantees
// matched nodes remain rooted).
func filterTree(roots []*ProjectTreeNode, keep map[uuid.UUID]struct{}) []*ProjectTreeNode {
	out := make([]*ProjectTreeNode, 0, len(roots))
	for _, r := range roots {
		if _, ok := keep[r.ID]; !ok {
			continue
		}
		r.Children = filterTree(r.Children, keep)
		out = append(out, r)
	}
	return out
}

// matchSet returns the set of node IDs for which match(node) returns true.
func matchSet(nodes map[uuid.UUID]*ProjectTreeNode, match func(*ProjectTreeNode) bool) map[uuid.UUID]struct{} {
	out := make(map[uuid.UUID]struct{})
	for id, n := range nodes {
		if match(n) {
			out[id] = struct{}{}
		}
	}
	return out
}

// applySearch tags MatchKind on the visible nodes and prunes the tree to
// keep only nodes whose subtree contains a match (or which are themselves
// a match). Match scope: case-fold contains on title, reference,
// client_number, matter_number. Ancestor titles match too via the
// pathClosure semantics.
func applySearch(nodes map[uuid.UUID]*ProjectTreeNode, roots *[]*ProjectTreeNode, term string) {
	q := strings.ToLower(term)
	matches := make(map[uuid.UUID]struct{})
	for id, n := range nodes {
		if matchesSearch(n, q) {
			matches[id] = struct{}{}
		}
	}
	if len(matches) == 0 {
		*roots = []*ProjectTreeNode{}
		return
	}
	// Path closure includes ancestors. Descendants of matches are also kept
	// (rendered as "descendant" so the user sees the full sub-context).
	keep := pathClosure(nodes, matches)
	descSet := make(map[uuid.UUID]struct{})
	addDescendants(nodes, matches, descSet)
	for id := range descSet {
		keep[id] = struct{}{}
	}

	for id := range keep {
		n, ok := nodes[id]
		if !ok {
			continue
		}
		switch {
		case isInSet(matches, id):
			n.MatchKind = "self"
		case isInSet(descSet, id):
			n.MatchKind = "descendant"
		default:
			n.MatchKind = "ancestor"
		}
	}

	*roots = filterTree(*roots, keep)
}

func matchesSearch(n *ProjectTreeNode, q string) bool {
	if strings.Contains(strings.ToLower(n.Title), q) {
		return true
	}
	if n.Reference != nil && strings.Contains(strings.ToLower(*n.Reference), q) {
		return true
	}
	if n.ClientNumber != nil && strings.Contains(strings.ToLower(*n.ClientNumber), q) {
		return true
	}
	if n.MatterNumber != nil && strings.Contains(strings.ToLower(*n.MatterNumber), q) {
		return true
	}
	return false
}

func addDescendants(nodes map[uuid.UUID]*ProjectTreeNode, seeds, out map[uuid.UUID]struct{}) {
	for seedID := range seeds {
		seed, ok := nodes[seedID]
		if !ok {
			continue
		}
		prefix := seed.Path + "."
		for id, n := range nodes {
			if id == seedID {
				continue
			}
			if strings.HasPrefix(n.Path, prefix) {
				out[id] = struct{}{}
			}
		}
	}
}

func isInSet(set map[uuid.UUID]struct{}, id uuid.UUID) bool {
	_, ok := set[id]
	return ok
}

func containsString(haystack []string, needle string) bool {
	return slices.Contains(haystack, needle)
}

func sortTreeByPath(nodes []*ProjectTreeNode) {
	for i := 1; i < len(nodes); i++ {
		for j := i; j > 0 && nodes[j].Path < nodes[j-1].Path; j-- {
			nodes[j], nodes[j-1] = nodes[j-1], nodes[j]
		}
	}
	for _, n := range nodes {
		sortTreeByPath(n.Children)
	}
}

// GetTree returns every Project in the subtree rooted at id (inclusive),
// ordered depth-first. Visibility-checked at root; descendants that the
// user can see are returned (the predicate naturally gates sub-branches).
func (s *ProjectService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) {
	root, err := s.GetByID(ctx, userID, id)
	if err != nil {
		return nil, err
	}
	// path LIKE root.path || '.%' OR path = root.path
	prefix := root.Path + ".%"
	query := `SELECT ` + projectColumns + ` FROM paliad.projects p
	          WHERE (p.path = $1 OR p.path LIKE $2)
	            AND ` + visibilityPredicatePositional("p", 3) + `
	          ORDER BY p.path`
	rows := []models.Project{}
	if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID); err != nil {
		return nil, fmt.Errorf("get tree: %w", err)
	}
	if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
		return nil, err
	}
	return rows, nil
}

// Create inserts a new Project. If parent_id is set, the creator must have
// visibility on the parent. The creator is auto-added to project_teams as
// role='lead' in the same transaction so post-create SELECT picks up the row.
func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input CreateProjectInput) (*models.Project, error) {
	if strings.TrimSpace(input.Title) == "" {
		return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
	}
	if !isValidProjectType(input.Type) {
		return nil, fmt.Errorf("%w: invalid type %q", ErrInvalidInput, input.Type)
	}
	user, err := s.users.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
	}
	if input.ParentID != nil {
		if _, err := s.GetByID(ctx, userID, *input.ParentID); err != nil {
			return nil, fmt.Errorf("%w: parent not visible", ErrForbidden)
		}
	}
	status := input.Status
	if status == "" {
		status = "active"
	}
	if err := validateProjectStatus(status); err != nil {
		return nil, err
	}
	if err := s.validateProceedingTypeCategory(ctx, input.ProceedingTypeID); 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()

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

	// path is NOT NULL but paliad.projects_sync_path() (BEFORE INSERT
	// trigger from mig 018/021) overwrites it from id and parent path,
	// so any non-null value satisfies the constraint. Use a literal
	// placeholder rather than re-referencing $1 — reusing a parameter
	// across columns with different SQL types (id is uuid, path is text)
	// makes Postgres's planner reject the statement with 42P08
	// "inconsistent types deduced for parameter" once the driver hands
	// $1 across as an inferred type. The literal keeps the param list
	// decoupled from the id column's type.
	if input.OurSide != nil {
		if err := validateOurSide(*input.OurSide); err != nil {
			return nil, err
		}
	}
	if input.OpponentCode != nil {
		if err := validateOpponentCode(*input.OpponentCode, input.Type); err != nil {
			return nil, err
		}
	}
	if input.InstanceLevel != nil {
		if err := validateInstanceLevel(*input.InstanceLevel); err != nil {
			return nil, err
		}
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.projects
		    (id, type, parent_id, path, title, reference, description, status,
		     created_by, industry, country, billing_reference, client_number,
		     matter_number, netdocuments_url, patent_number, filing_date, grant_date,
		     court, case_number, proceeding_type_id, our_side, opponent_code,
		     counterclaim_of, instance_level, metadata, created_at, updated_at)
		 VALUES ($1, $2, $3, '', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
		         $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, '{}'::jsonb, $25, $25)`,
		id, input.Type, input.ParentID,
		input.Title, input.Reference, input.Description, status,
		userID,
		input.Industry, input.Country, input.BillingReference,
		nullableTrimmed(input.ClientNumber), nullableTrimmed(input.MatterNumber), input.NetDocumentsURL,
		input.PatentNumber, input.FilingDate, input.GrantDate,
		input.Court, input.CaseNumber, input.ProceedingTypeID,
		nullableOurSide(input.OurSide),
		nullableOpponentCode(input.OpponentCode),
		input.CounterclaimOf,
		nullableInstanceLevel(input.InstanceLevel),
		now,
	); err != nil {
		return nil, fmt.Errorf("insert project: %w", err)
	}

	// Auto-add creator as team lead so they (and RLS) can see the row.
	// Writes both the legacy `role` and the new `responsibility` so the
	// deprecated shadow column stays in sync until migration 058.
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
		 VALUES ($1, $2, 'lead', 'lead', false, $2)`, id, userID); err != nil {
		return nil, fmt.Errorf("insert creator team row: %w", err)
	}

	if err := insertProjectEvent(ctx, tx, id, userID, "project_created", "Project created", nil); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit create project: %w", err)
	}
	return s.GetByID(ctx, userID, id)
}

// Update applies a partial update. Reparenting triggers path rewrite for the
// subtree (handled by the AFTER UPDATE trigger on paliad.projects).
func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateProjectInput) (*models.Project, error) {
	current, err := s.GetByID(ctx, userID, id)
	if err != nil {
		return nil, err
	}
	if input.ParentID != nil {
		// Verify new parent is visible (reparenting under invisible node would
		// leak the whole subtree to the new parent's team — reject).
		if _, err := s.GetByID(ctx, userID, *input.ParentID); err != nil {
			return nil, fmt.Errorf("%w: new parent not visible", ErrForbidden)
		}
	}

	// Type change: validate up-front and collect the columns that were
	// specific to the old type. Those get force-NULL'd at the end of the SET
	// list and the per-field appendSet calls below skip them — Postgres
	// rejects duplicate column assignments in a single UPDATE, and the
	// type-change clear has to win regardless of what the client sent.
	typeChanged := false
	clearOnTypeChange := map[string]bool{}
	if input.Type != nil && *input.Type != current.Type {
		if !isValidProjectType(*input.Type) {
			return nil, fmt.Errorf("%w: invalid type %q", ErrInvalidInput, *input.Type)
		}
		for _, col := range typeSpecificColumns(current.Type) {
			clearOnTypeChange[col] = true
		}
		typeChanged = true
	}

	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++
	}
	// appendSetSkippable is for per-field user input that must yield to the
	// type-change clear when the column is being forced to NULL.
	appendSetSkippable := func(col string, val any) {
		if clearOnTypeChange[col] {
			return
		}
		appendSet(col, val)
	}

	if typeChanged {
		appendSet("type", *input.Type)
	}
	if input.Title != nil {
		t := strings.TrimSpace(*input.Title)
		if t == "" {
			return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
		}
		appendSet("title", t)
	}
	if input.Reference != nil {
		appendSet("reference", *input.Reference)
	}
	if input.Description != nil {
		appendSet("description", *input.Description)
	}
	if input.Status != nil {
		if err := validateProjectStatus(*input.Status); err != nil {
			return nil, err
		}
		appendSet("status", *input.Status)
	}
	if input.ParentID != nil {
		appendSet("parent_id", *input.ParentID)
	}
	if input.Industry != nil {
		appendSetSkippable("industry", *input.Industry)
	}
	if input.Country != nil {
		appendSetSkippable("country", *input.Country)
	}
	if input.BillingReference != nil {
		appendSet("billing_reference", *input.BillingReference)
	}
	if input.ClientNumber != nil {
		// Coerce empty string → NULL so the
		// projekte_client_number_check ('^[0-9]{6}$' OR NULL) accepts a
		// blanked field (m, 2026-05-22 — "I cant add a project").
		appendSetSkippable("client_number", nullableTrimmed(input.ClientNumber))
	}
	if input.MatterNumber != nil {
		appendSet("matter_number", nullableTrimmed(input.MatterNumber))
	}
	if input.NetDocumentsURL != nil {
		appendSet("netdocuments_url", *input.NetDocumentsURL)
	}
	if input.PatentNumber != nil {
		appendSetSkippable("patent_number", *input.PatentNumber)
	}
	if input.FilingDate != nil {
		appendSetSkippable("filing_date", *input.FilingDate)
	}
	if input.GrantDate != nil {
		appendSetSkippable("grant_date", *input.GrantDate)
	}
	if input.Court != nil {
		appendSetSkippable("court", *input.Court)
	}
	if input.CaseNumber != nil {
		appendSetSkippable("case_number", *input.CaseNumber)
	}
	if input.ProceedingTypeID != nil {
		if err := s.validateProceedingTypeCategory(ctx, input.ProceedingTypeID); err != nil {
			return nil, err
		}
		appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID)
	}
	if input.OurSide != nil {
		if err := validateOurSide(*input.OurSide); err != nil {
			return nil, err
		}
		appendSet("our_side", nullableOurSide(input.OurSide))
	}
	if input.OpponentCode != nil {
		if err := validateOpponentCode(*input.OpponentCode, current.Type); err != nil {
			return nil, err
		}
		appendSet("opponent_code", nullableOpponentCode(input.OpponentCode))
	}
	if input.InstanceLevel != nil {
		if err := validateInstanceLevel(*input.InstanceLevel); err != nil {
			return nil, err
		}
		appendSet("instance_level", nullableInstanceLevel(input.InstanceLevel))
	}
	if typeChanged {
		for _, col := range typeSpecificColumns(current.Type) {
			appendSet(col, nil)
		}
	}
	if len(sets) == 0 {
		return current, nil
	}
	appendSet("updated_at", time.Now().UTC())

	args = append(args, id)
	query := fmt.Sprintf("UPDATE paliad.projects 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 project: %w", err)
	}

	// Descriptions carry the value-only payload (`old → new`); the frontend
	// renderer translates both slugs and prepends the localized prefix.
	if input.Status != nil && *input.Status != current.Status {
		desc := fmt.Sprintf("%s → %s", current.Status, *input.Status)
		descPtr := &desc
		if err := insertProjectEvent(ctx, tx, id, userID, "status_changed", "Status changed", descPtr); err != nil {
			return nil, err
		}
	}
	if typeChanged {
		desc := fmt.Sprintf("%s → %s", current.Type, *input.Type)
		descPtr := &desc
		if err := insertProjectEvent(ctx, tx, id, userID, "project_type_changed", "Project type changed", descPtr); err != nil {
			return nil, err
		}
	}
	if input.ParentID != nil {
		if err := insertProjectEvent(ctx, tx, id, userID, "project_reparented", "Project re-parented", nil); err != nil {
			return nil, err
		}
	}
	// our_side change: log when the value (or its set/unset state) actually
	// flips. Description follows the same value-only "old → new" pattern as
	// status_changed; frontend renderer maps the slugs to localized labels
	// (claimant / defendant / court / both / "—" for NULL).
	if input.OurSide != nil {
		nextOS := strings.TrimSpace(*input.OurSide)
		prevOS := ""
		if current.OurSide != nil {
			prevOS = *current.OurSide
		}
		if nextOS != prevOS {
			from := prevOS
			if from == "" {
				from = "none"
			}
			to := nextOS
			if to == "" {
				to = "none"
			}
			desc := fmt.Sprintf("%s → %s", from, to)
			descPtr := &desc
			if err := insertProjectEvent(ctx, tx, id, userID, "our_side_changed", "Represented side changed", descPtr); err != nil {
				return nil, err
			}
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit update project: %w", err)
	}
	return s.GetByID(ctx, userID, id)
}

// validateProceedingTypeCategory enforces the project-binding invariants
// on paliad.projects.proceeding_type_id:
//
//  1. Phase 3 Slice 5 (t-paliad-186, design §3.F): row must be
//     category='fristenrechner'. DB-side backstop: mig 088 trigger.
//     Surfaces ErrInvalidProceedingTypeCategory.
//
//  2. Mig 153 (t-paliad-325, design §1 + m's Q8): row must be
//     kind='proceeding' AND is_active=true. DB-side backstop: mig 153
//     trigger. Surfaces ErrInvalidProceedingTypeKind. Rejects phase /
//     side_action / meta rows and any deactivated row.
//
// NULL passes through. The Go layer fires first so handlers get typed
// errors; the DB triggers catch any writer that bypasses the service.
func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error {
	if ptID == nil {
		return nil
	}
	var row struct {
		Category sql.NullString `db:"category"`
		Kind     sql.NullString `db:"kind"`
		IsActive bool           `db:"is_active"`
	}
	if err := s.db.GetContext(ctx, &row,
		`SELECT category, kind, is_active FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID)
		}
		return fmt.Errorf("lookup proceeding_type: %w", err)
	}
	if !row.Category.Valid || row.Category.String != "fristenrechner" {
		return fmt.Errorf("%w: proceeding_type_id=%d has category=%q",
			ErrInvalidProceedingTypeCategory, *ptID, row.Category.String)
	}
	if !row.Kind.Valid || row.Kind.String != "proceeding" {
		return fmt.Errorf("%w: proceeding_type_id=%d has kind=%q",
			ErrInvalidProceedingTypeKind, *ptID, row.Kind.String)
	}
	if !row.IsActive {
		return fmt.Errorf("%w: proceeding_type_id=%d is inactive",
			ErrInvalidProceedingTypeKind, *ptID)
	}
	return nil
}

// Delete archives the Project (soft-delete, status='archived'). Partner/admin only.
// Hard-delete cascades through FK; we prefer archival for audit.
func (s *ProjectService) Delete(ctx context.Context, userID, id 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 archive Projects", ErrForbidden)
	}
	if _, err := s.GetByID(ctx, userID, id); err != nil {
		return err
	}

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

	res, err := tx.ExecContext(ctx,
		`UPDATE paliad.projects SET status = 'archived', updated_at = $1
		  WHERE id = $2 AND status != 'archived'`, time.Now().UTC(), id)
	if err != nil {
		return fmt.Errorf("archive project: %w", err)
	}
	if rows, _ := res.RowsAffected(); rows == 0 {
		return tx.Commit()
	}
	if err := insertProjectEvent(ctx, tx, id, userID, "project_archived", "Project archived", nil); err != nil {
		return err
	}
	return tx.Commit()
}

// CounterclaimOpts narrows CreateCounterclaim. Empty zero values fall back
// to the design defaults: proceeding_type_id = upc.rev.cfi, our_side = inverted
// from the parent, title = "<patent reference> — Widerklage (CCR)" when a
// patent reference is resolvable, else "<parent title> — Widerklage".
//
// FlipOurSide is a tri-state via *bool to distinguish "default-flip" (nil)
// from the explicit "Stimmt nicht?" override (false = keep parent's side,
// true = flip explicitly). The R.49.2.b CCI edge case is the reason this
// override exists (see docs/design-smart-timeline-2026-05-08.md §11 Q2).
type CounterclaimOpts struct {
	ProceedingTypeID *int
	FlipOurSide      *bool
	Title            *string
	CaseNumber       *string
}

// LoadCounterclaimChildrenVisible returns the CCR sub-projects filed
// against parentID that the caller can see. Each row is a normal
// paliad.projects row with counterclaim_of=parentID. Used by the
// SmartTimeline to render parallel right-tracks (t-paliad-174 §4.5).
func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, userID, parentID uuid.UUID) ([]models.Project, error) {
	user, err := s.users.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return []models.Project{}, nil
	}
	rows := []models.Project{}
	query := `SELECT ` + projectColumns + ` FROM paliad.projects p
	           WHERE p.counterclaim_of = $1
	             AND ` + visibilityPredicatePositional("p", 2) + `
	         ORDER BY p.created_at ASC, p.id ASC`
	if err := s.db.SelectContext(ctx, &rows, query, parentID, userID); err != nil {
		return nil, fmt.Errorf("load counterclaim children: %w", err)
	}
	if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
		return nil, err
	}
	return rows, nil
}

// CreateCounterclaim creates a CCR sub-project against parentID. Atomic:
// project + creator-as-lead team membership + audit rows on parent AND
// child are all written in a single transaction.
//
// Placement (§4.4): the CCR child is a sibling under the same patent —
// child.parent_id = parent.parent_id. When the parent has no parent_id
// (root case at the top of its tree) we fall back to parent.id as the
// CCR child's parent so the row remains in the same subtree.
//
// our_side flip (§11 Q2): default-inverts claimant↔defendant; "court"
// and "both" pass through unchanged. The opts.FlipOurSide override
// supports the rare R.49.2.b CCI shape where flipping is wrong.
//
// proceeding_type_id default (§4.4): upc.rev.cfi for the standard CCR-on-
// validity. UPC_CCI is the rarer R.49.2.b path; callers pass the id
// explicitly when they want it.
func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentID uuid.UUID, opts CounterclaimOpts) (*models.Project, error) {
	user, err := s.users.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
	}
	parent, err := s.GetByID(ctx, userID, parentID)
	if err != nil {
		return nil, err
	}
	if parent.CounterclaimOf != nil {
		return nil, fmt.Errorf("%w: parent project is itself a counterclaim — two-level CCR chains are not allowed", ErrInvalidInput)
	}

	// Resolve proceeding_type_id default to upc.rev.cfi when caller didn't
	// override. The DB row is required because the projection layer
	// dereferences it (paliad.proceeding_types.code).
	procTypeID := 0
	if opts.ProceedingTypeID != nil {
		procTypeID = *opts.ProceedingTypeID
	} else {
		err := s.db.GetContext(ctx, &procTypeID,
			`SELECT id FROM paliad.proceeding_types
			  WHERE code = $1 AND is_active = true`, CodeUPCRevocation)
		if err != nil {
			return nil, fmt.Errorf("resolve default %s proceeding type: %w", CodeUPCRevocation, err)
		}
	}

	childOurSide := derivedCounterclaimOurSide(parent.OurSide, opts.FlipOurSide)
	childParentID := parent.ParentID
	if childParentID == nil {
		// Parent has no parent_id (root case at the top of its tree).
		// Fall back to parent.id so the CCR child stays in the same
		// subtree rather than becoming a new root. The visibility
		// predicate inherits cleanly either way.
		fallback := parent.ID
		childParentID = &fallback
	}

	// Resolve the best patent reference for the suggested title — when
	// parent is a case, the patent_number lives on its patent ancestor.
	patentRef := s.resolvePatentReferenceForTitle(ctx, userID, parent)
	title := derivedCounterclaimTitle(parent, patentRef, opts.Title)

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

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

	// path placeholder is overwritten by paliad.projects_sync_path();
	// same rationale as ProjectService.Create — see comment there for
	// why we use a literal '' instead of re-referencing $1.
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.projects
		    (id, type, parent_id, path, title, status, created_by,
		     court, case_number, proceeding_type_id, our_side, counterclaim_of,
		     metadata, created_at, updated_at)
		 VALUES ($1, 'case', $2, '', $3, 'active', $4,
		         $5, $6, $7, $8, $9, '{}'::jsonb, $10, $10)`,
		id, childParentID, title, userID,
		parent.Court, opts.CaseNumber, procTypeID,
		nullableOurSide(&childOurSide), parentID, now,
	); err != nil {
		return nil, fmt.Errorf("insert counterclaim project: %w", err)
	}

	// Auto-add creator as team lead on the new CCR row so RLS lets the
	// caller see the project they just made. Mirrors Create.
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
		 VALUES ($1, $2, 'lead', 'lead', false, $2)`, id, userID); err != nil {
		return nil, fmt.Errorf("insert creator team row: %w", err)
	}

	// Audit rows on both parent and child for symmetric trail. Both rows
	// opt into the SmartTimeline via timeline_kind='milestone'. The
	// bubble_up=true flag (t-paliad-175 §5.3 Q5) lets these structural
	// milestones surface on Patent / Litigation / Client SmartTimelines
	// even though the level policy filters out other milestones.
	if err := insertCounterclaimEvent(ctx, tx, id, userID,
		"Widerklage (CCR) angelegt",
		map[string]any{
			"counterclaim_of": parentID.String(),
			"bubble_up":       true,
		},
	); err != nil {
		return nil, err
	}
	if err := insertCounterclaimEvent(ctx, tx, parentID, userID,
		"Widerklage (CCR) angelegt",
		map[string]any{
			"counterclaim_id": id.String(),
			"bubble_up":       true,
		},
	); err != nil {
		return nil, err
	}

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

// insertCounterclaimEvent writes a paliad.project_events row with
// event_type='counterclaim_created' AND timeline_kind='milestone' so
// the audit row surfaces on the SmartTimeline by default. Matches the
// pattern Slice 1 established for opt-in milestones (§2.2).
func insertCounterclaimEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, title string, meta map[string]any) error {
	now := time.Now().UTC()
	metaJSON := json.RawMessage(`{}`)
	if len(meta) > 0 {
		b, err := json.Marshal(meta)
		if err != nil {
			return fmt.Errorf("marshal counterclaim_created metadata: %w", err)
		}
		metaJSON = b
	}
	_, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.project_events
		    (id, project_id, event_type, title, description, event_date,
		     created_by, metadata, created_at, updated_at, timeline_kind)
		 VALUES ($1, $2, 'counterclaim_created', $3, NULL, $4, $5, $6, $4, $4, 'milestone')`,
		uuid.New(), projectID, title, now, userID, metaJSON)
	if err != nil {
		return fmt.Errorf("insert counterclaim_created event: %w", err)
	}
	return nil
}

// derivedCounterclaimOurSide computes the child's our_side from the
// parent's our_side and the opts.FlipOurSide override.
//
// Default (override nil OR override=true): flip across the active /
// reactive axis using the t-paliad-222 sub-role table —
//
//   claimant   ↔ defendant
//   applicant  ↔ respondent
//   appellant  → respondent   (the CCR-against-appellant is the
//                              defending position; appellant has no
//                              symmetric counter-role in the new set)
//
// Third Party / Other (third_party, other) and NULL pass through
// unchanged — the flip is meaningless without a clear active / reactive
// posture. Legacy 'court' / 'both' no longer exist in the column
// (mig 112) so they have no case arm; if a stale value sneaks in via a
// pre-migration in-memory row it falls through to the default branch
// and passes through unchanged, preserving previous behaviour.
//
// Override=false: keep parent's side as-is. R.49.2.b CCI is the named
// edge case where the CCR sub-project shares the parent's perspective.
func derivedCounterclaimOurSide(parentSide *string, override *bool) string {
	if parentSide == nil {
		return ""
	}
	side := strings.TrimSpace(*parentSide)
	flip := true
	if override != nil {
		flip = *override
	}
	if !flip {
		return side
	}
	switch side {
	case "claimant":
		return "defendant"
	case "defendant":
		return "claimant"
	case "applicant":
		return "respondent"
	case "respondent":
		return "applicant"
	case "appellant":
		return "respondent"
	default:
		return side
	}
}

// resolvePatentReferenceForTitle returns the closest patent_number /
// reference to use as the CCR title prefix. Parent is usually a case
// row (no patent_number on it) — walks up ancestors to find the patent
// hub. Best-effort: returns empty when no patent ancestor is visible.
func (s *ProjectService) resolvePatentReferenceForTitle(ctx context.Context, userID uuid.UUID, parent *models.Project) string {
	if parent.PatentNumber != nil && strings.TrimSpace(*parent.PatentNumber) != "" {
		return strings.TrimSpace(*parent.PatentNumber)
	}
	ancestors, err := s.ListAncestors(ctx, userID, parent.ID)
	if err != nil || len(ancestors) == 0 {
		return ""
	}
	for i := len(ancestors) - 1; i >= 0; i-- {
		a := ancestors[i]
		if a.PatentNumber != nil && strings.TrimSpace(*a.PatentNumber) != "" {
			return strings.TrimSpace(*a.PatentNumber)
		}
	}
	return ""
}

// derivedCounterclaimTitle picks the auto-suggested title for the CCR
// child. Override wins when supplied; otherwise prefers the patent
// reference, then parent.reference, then parent.title — each yields
// "<ref> — Widerklage (CCR)".
func derivedCounterclaimTitle(parent *models.Project, patentRef string, override *string) string {
	if override != nil {
		v := strings.TrimSpace(*override)
		if v != "" {
			return v
		}
	}
	suffix := " — Widerklage (CCR)"
	if patentRef != "" {
		return patentRef + suffix
	}
	if parent.Reference != nil && strings.TrimSpace(*parent.Reference) != "" {
		return strings.TrimSpace(*parent.Reference) + suffix
	}
	return strings.TrimSpace(parent.Title) + suffix
}

// MaxEventsPageLimit caps ListEvents page size.
const MaxEventsPageLimit = 200

// DefaultEventsPageLimit is the page size when ?limit= is omitted.
const DefaultEventsPageLimit = 50

// ListEvents returns the audit trail for the Project, newest first, with
// cursor pagination (before = uuid of last seen event).
//
// When directOnly is false (default), the result aggregates events from
// the Project itself AND every descendant Project (per the t-paliad-139
// hierarchy aggregation contract — Verlauf on a Client should show the
// matter's complete history, not just rows attached at the root). When
// directOnly is true, only events whose project_id exactly equals the
// filter are returned.
func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int, directOnly bool) ([]models.ProjectEvent, error) {
	if _, err := s.GetByID(ctx, userID, id); err != nil {
		return nil, err
	}
	if limit <= 0 {
		limit = DefaultEventsPageLimit
	}
	if limit > MaxEventsPageLimit {
		limit = MaxEventsPageLimit
	}
	var beforeArg any
	if before != nil {
		beforeArg = *before
	}
	var projectFilter string
	if directOnly {
		projectFilter = `project_id = $1`
	} else {
		// Inner alias `pp` to avoid shadowing the outer `p` JOIN below.
		projectFilter = `project_id IN (
		    SELECT pp.id FROM paliad.projects pp
		     WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[]))`
	}
	var events []models.ProjectEvent
	err := s.db.SelectContext(ctx, &events,
		`SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description, pe.event_date,
		        pe.created_by, pe.metadata, pe.created_at, pe.updated_at,
		        p.title AS project_title
		   FROM paliad.project_events pe
		   LEFT JOIN paliad.projects p ON p.id = pe.project_id
		  WHERE pe.`+projectFilter+`
		    AND ($2::uuid IS NULL OR (pe.created_at, pe.id) < (
		          SELECT created_at, id FROM paliad.project_events WHERE id = $2::uuid
		    ))
		  ORDER BY pe.created_at DESC, pe.id DESC
		  LIMIT $3`, id, beforeArg, limit)
	if err != nil {
		return nil, fmt.Errorf("list project events: %w", err)
	}
	return events, nil
}

// ResolveClientNumber walks up the path to find the first non-null client_number
// (inherited convention). Returns nil if none in the ancestor chain.
func (s *ProjectService) ResolveClientNumber(ctx context.Context, userID, id uuid.UUID) (*string, error) {
	p, err := s.GetByID(ctx, userID, id)
	if err != nil {
		return nil, err
	}
	if p.ClientNumber != nil {
		return p.ClientNumber, nil
	}
	ancestors, err := s.ListAncestors(ctx, userID, id)
	if err != nil {
		return nil, err
	}
	// Ancestors returned root→parent; scan from closest ancestor outward —
	// but client_number is conceptually set at the root, so walking either
	// direction is fine. Closest wins for override.
	for i := len(ancestors) - 1; i >= 0; i-- {
		if ancestors[i].ClientNumber != nil {
			return ancestors[i].ClientNumber, nil
		}
	}
	return nil, nil
}

// ============================================================================
// Cards preview (t-paliad-149 PR 2)
// ============================================================================

// CardEventPreview is one event row inside a card's "Nächste Termine" or
// "Zuletzt" section. Hoverable + clickable in the UI; route is the
// computed in-app navigation target.
type CardEventPreview struct {
	Kind      string    `json:"kind"`            // "deadline" | "appointment" | "project_event"
	ID        uuid.UUID `json:"id"`
	Title     string    `json:"title"`
	EventDate time.Time `json:"event_date"`
	Status    *string   `json:"status,omitempty"`     // populated for kind=deadline
	ActorName *string   `json:"actor_name,omitempty"` // populated for kind=project_event
	Route     string    `json:"route"`                // /projects/{pid}?focus=...
}

// ProjectCardPreview is the per-project rollup for the Cards view. One row
// per visible project; team_initials capped at 3 + team_count for the
// total. last_activity_at is the most recent event timestamp (deadline /
// appointment / project_event) across own + descendants, used by the
// orchestrator to sort cards.
type ProjectCardPreview struct {
	ProjectID      uuid.UUID          `json:"project_id"`
	NextEvents     []CardEventPreview `json:"next_events"`
	RecentVerlauf  []CardEventPreview `json:"recent_verlauf"`
	TeamInitials   []string           `json:"team_initials"`
	TeamCount      int                `json:"team_count"`
	LastActivityAt *time.Time         `json:"last_activity_at,omitempty"`
}

// CardsPreview returns the per-project rollup for the Cards view across
// every project the user can see. The optional projectIDs slice narrows
// the rollup to a subset (used by IntersectionObserver lazy fetches).
//
// Performance: a single SQL per source (deadlines, appointments, project
// events) using ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY
// event_date) to slice top-3 each direction without N round-trips. Caller
// can wrap in a per-user TTL cache (handler does this v1).
func (s *ProjectService) CardsPreview(ctx context.Context, userID uuid.UUID, projectIDs []uuid.UUID) (map[uuid.UUID]*ProjectCardPreview, error) {
	user, err := s.users.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return map[uuid.UUID]*ProjectCardPreview{}, nil
	}

	// Determine the visible-project set. When projectIDs is non-empty, we
	// still gate every row through the visibility predicate.
	out := map[uuid.UUID]*ProjectCardPreview{}

	// Optional narrowing as a SQL ANY clause.
	narrow := ""
	args := []any{userID}
	if len(projectIDs) > 0 {
		idStrs := make([]string, len(projectIDs))
		for i, id := range projectIDs {
			idStrs[i] = id.String()
		}
		narrow = " AND p.id = ANY($2::uuid[])"
		args = append(args, pq.StringArray(idStrs))
	}

	now := time.Now().UTC()

	// --- Source 1: upcoming Deadlines (top 3 per project, ascending). ---
	type rowDeadline struct {
		ProjectID uuid.UUID `db:"project_id"`
		ID        uuid.UUID `db:"id"`
		Title     string    `db:"title"`
		DueDate   time.Time `db:"due_date"`
		Status    string    `db:"status"`
	}
	var ds []rowDeadline
	// Include every pending deadline regardless of due_date — overdue
	// deadlines are MORE urgent than upcoming ones, not less, so a card
	// labelled "Nächste Termine" must surface them first. Sort ASC so the
	// most-overdue lands at the top, naturally followed by today / soon
	// (m, 2026-05-08 15:02 — "5 offen" was visible but Nächste Termine
	// stayed empty because the >= today filter dropped overdue pending).
	dq := `
		WITH visible AS (
		  SELECT p.id FROM paliad.projects p
		   WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
		), ranked AS (
		  SELECT f.project_id, f.id, f.title, f.due_date, f.status,
		         ROW_NUMBER() OVER (
		           PARTITION BY f.project_id
		           ORDER BY f.due_date ASC, f.id ASC
		         ) AS rn
		    FROM paliad.deadlines f
		    JOIN visible v ON v.id = f.project_id
		   WHERE f.status = 'pending'
		)
		SELECT project_id, id, title, due_date, status
		  FROM ranked WHERE rn <= 3
	`
	if err := s.db.SelectContext(ctx, &ds, dq, args...); err != nil {
		return nil, fmt.Errorf("cards preview deadlines: %w", err)
	}

	// --- Source 2: upcoming Appointments (top 3 per project, ascending). ---
	// Past appointments stay excluded (they're history, not "next") —
	// unlike deadlines where overdue-pending is more urgent than upcoming.
	type rowAppointment struct {
		ProjectID uuid.UUID `db:"project_id"`
		ID        uuid.UUID `db:"id"`
		Title     string    `db:"title"`
		StartsAt  time.Time `db:"start_at"`
	}
	var as []rowAppointment
	// paliad.appointments column is `start_at` (singular). Earlier this
	// query used `starts_at` which does not exist — the resulting query
	// error short-circuited CardsPreview, the handler 500'd, and every
	// project card on /projects rendered "keine bevorstehenden Termine"
	// regardless of how many deadlines were actually pending. Caught
	// 2026-05-08 21:16 against m's UPC-CoA Berufung Huawei card (4 open
	// deadlines visible from the count, zero in the preview).
	aq := `
		WITH visible AS (
		  SELECT p.id FROM paliad.projects p
		   WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
		), ranked AS (
		  SELECT t.project_id, t.id, t.title, t.start_at,
		         ROW_NUMBER() OVER (
		           PARTITION BY t.project_id
		           ORDER BY t.start_at ASC, t.id ASC
		         ) AS rn
		    FROM paliad.appointments t
		    JOIN visible v ON v.id = t.project_id
		   WHERE t.project_id IS NOT NULL AND t.start_at >= $%d::timestamptz
		)
		SELECT project_id, id, title, start_at
		  FROM ranked WHERE rn <= 3
	`
	aArgs := make([]any, 0, len(args)+1)
	aArgs = append(aArgs, args...)
	aArgs = append(aArgs, now)
	aq = fmt.Sprintf(aq, len(aArgs))
	if err := s.db.SelectContext(ctx, &as, aq, aArgs...); err != nil {
		return nil, fmt.Errorf("cards preview appointments: %w", err)
	}

	// --- Source 3: recent project_events (Verlauf, top 3 per project, descending). ---
	type rowEvent struct {
		ProjectID uuid.UUID  `db:"project_id"`
		ID        uuid.UUID  `db:"id"`
		Title     string     `db:"title"`
		CreatedAt time.Time  `db:"created_at"`
		ActorName *string    `db:"actor_name"`
	}
	var es []rowEvent
	eq := `
		WITH visible AS (
		  SELECT p.id FROM paliad.projects p
		   WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
		), ranked AS (
		  SELECT pe.project_id, pe.id, pe.title, pe.created_at,
		         u.display_name AS actor_name,
		         ROW_NUMBER() OVER (
		           PARTITION BY pe.project_id
		           ORDER BY pe.created_at DESC, pe.id DESC
		         ) AS rn
		    FROM paliad.project_events pe
		    JOIN visible v ON v.id = pe.project_id
		    LEFT JOIN paliad.users u ON u.id = pe.created_by
		)
		SELECT project_id, id, title, created_at, actor_name
		  FROM ranked WHERE rn <= 3
	`
	eArgs := []any{userID}
	if len(projectIDs) > 0 {
		idStrs := make([]string, len(projectIDs))
		for i, id := range projectIDs {
			idStrs[i] = id.String()
		}
		eArgs = append(eArgs, pq.StringArray(idStrs))
	}
	if err := s.db.SelectContext(ctx, &es, eq, eArgs...); err != nil {
		return nil, fmt.Errorf("cards preview project events: %w", err)
	}

	// --- Source 4: team chips per project (initials + count). ---
	type rowTeam struct {
		ProjectID   uuid.UUID `db:"project_id"`
		DisplayName string    `db:"display_name"`
	}
	var ts []rowTeam
	tq := `
		WITH visible AS (
		  SELECT p.id FROM paliad.projects p
		   WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
		)
		SELECT pt.project_id, u.display_name
		  FROM paliad.project_teams pt
		  JOIN visible v ON v.id = pt.project_id
		  JOIN paliad.users u ON u.id = pt.user_id
		 ORDER BY pt.project_id, u.display_name
	`
	if err := s.db.SelectContext(ctx, &ts, tq, eArgs...); err != nil {
		return nil, fmt.Errorf("cards preview teams: %w", err)
	}

	// Stitch into per-project structs.
	get := func(pid uuid.UUID) *ProjectCardPreview {
		if p, ok := out[pid]; ok {
			return p
		}
		p := &ProjectCardPreview{
			ProjectID:     pid,
			NextEvents:    []CardEventPreview{},
			RecentVerlauf: []CardEventPreview{},
			TeamInitials:  []string{},
		}
		out[pid] = p
		return p
	}
	for _, r := range ds {
		p := get(r.ProjectID)
		st := r.Status
		p.NextEvents = append(p.NextEvents, CardEventPreview{
			Kind:      "deadline",
			ID:        r.ID,
			Title:     r.Title,
			EventDate: r.DueDate,
			Status:    &st,
			Route:     fmt.Sprintf("/projects/%s?focus=%s", r.ProjectID, r.ID),
		})
		bumpActivity(p, r.DueDate)
	}
	for _, r := range as {
		p := get(r.ProjectID)
		p.NextEvents = append(p.NextEvents, CardEventPreview{
			Kind:      "appointment",
			ID:        r.ID,
			Title:     r.Title,
			EventDate: r.StartsAt,
			Route:     fmt.Sprintf("/projects/%s?focus=%s", r.ProjectID, r.ID),
		})
		bumpActivity(p, r.StartsAt)
	}
	for _, r := range es {
		p := get(r.ProjectID)
		p.RecentVerlauf = append(p.RecentVerlauf, CardEventPreview{
			Kind:      "project_event",
			ID:        r.ID,
			Title:     r.Title,
			EventDate: r.CreatedAt,
			ActorName: r.ActorName,
			Route:     fmt.Sprintf("/projects/%s?tab=verlauf&focus=%s", r.ProjectID, r.ID),
		})
		bumpActivity(p, r.CreatedAt)
	}
	for _, r := range ts {
		p := get(r.ProjectID)
		p.TeamCount++
		if len(p.TeamInitials) < 3 {
			p.TeamInitials = append(p.TeamInitials, initialsFromName(r.DisplayName))
		}
	}

	// Sort NextEvents per project ascending, RecentVerlauf descending,
	// then truncate to 3 (the SQL caps at 3 per source, but the union of
	// deadline+appointment can be 6 — re-sort + cap to 3).
	for _, p := range out {
		sortByEventDateAsc(p.NextEvents)
		if len(p.NextEvents) > 3 {
			p.NextEvents = p.NextEvents[:3]
		}
		// RecentVerlauf is single-source already-bounded; nothing else to do.
		_ = p.RecentVerlauf
	}

	return out, nil
}

func bumpActivity(p *ProjectCardPreview, ts time.Time) {
	if p.LastActivityAt == nil || ts.After(*p.LastActivityAt) {
		t := ts
		p.LastActivityAt = &t
	}
}

func sortByEventDateAsc(events []CardEventPreview) {
	for i := 1; i < len(events); i++ {
		for j := i; j > 0 && events[j].EventDate.Before(events[j-1].EventDate); j-- {
			events[j], events[j-1] = events[j-1], events[j]
		}
	}
}

func initialsFromName(name string) string {
	parts := strings.Fields(name)
	if len(parts) == 0 {
		return "?"
	}
	if len(parts) == 1 {
		r := []rune(parts[0])
		if len(r) == 0 {
			return "?"
		}
		return strings.ToUpper(string(r[0]))
	}
	first := []rune(parts[0])
	last := []rune(parts[len(parts)-1])
	if len(first) == 0 || len(last) == 0 {
		return strings.ToUpper(string(first) + string(last))
	}
	return strings.ToUpper(string(first[0]) + string(last[0]))
}

// ============================================================================
// Helpers
// ============================================================================

// insertProjectEvent appends one audit row in the given tx.
func insertProjectEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, eventType, title string, description *string) error {
	return insertProjectEventWithMeta(ctx, tx, projectID, userID, eventType, title, description, nil)
}

// insertProjectEventWithMeta appends an audit row with structured metadata
// (e.g. {"checklist_instance_id": "..."}). The metadata column is a free-form
// jsonb; readers query specific keys when present (see Verlauf rendering of
// checklist_* events) and ignore unknown keys.
func insertProjectEventWithMeta(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, eventType, title string, description *string, meta map[string]any) error {
	now := time.Now().UTC()
	metaJSON := json.RawMessage(`{}`)
	if len(meta) > 0 {
		b, err := json.Marshal(meta)
		if err != nil {
			return fmt.Errorf("marshal project_event metadata: %w", err)
		}
		metaJSON = b
	}
	_, err := tx.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, $3, $4, $5, $6, $7, $8, $6, $6)`,
		uuid.New(), projectID, eventType, title, description, now, userID, metaJSON)
	if err != nil {
		return fmt.Errorf("insert project_event: %w", err)
	}
	return nil
}

// typeSpecificColumns returns the DB columns that only make sense for the
// given project type. When a project's type changes away from `t`, callers
// NULL these columns so the row doesn't carry stale data from the old type.
// Litigation/project have no specific columns.
func typeSpecificColumns(t string) []string {
	switch t {
	case ProjectTypeClient:
		return []string{"industry", "country", "client_number"}
	case ProjectTypePatent:
		return []string{"patent_number", "filing_date", "grant_date"}
	case ProjectTypeCase:
		return []string{"court", "case_number", "proceeding_type_id"}
	}
	return nil
}

func isValidProjectType(t string) bool {
	switch t {
	case ProjectTypeClient, ProjectTypeLitigation, ProjectTypePatent,
		ProjectTypeCase, ProjectTypeProject, ProjectTypeOther:
		return true
	}
	return false
}

func validateProjectStatus(s string) error {
	switch s {
	case "active", "archived", "closed":
		return nil
	}
	return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
}

// validateOurSide checks the project-level "Client Role" enum
// (t-paliad-164, widened in t-paliad-222 / m/paliad#47). Empty string
// is the explicit "clear" sentinel — callers pass the value as-is
// from the form payload, and the helper accepts it so an Update can
// null the column. The DB-level CHECK constraint (mig 112) enforces
// the same set; this validation gives a clearer error than relying
// on the constraint to fire.
//
// Allowed sub-roles, grouped at display time:
//   Active (we initiate)  : claimant, applicant, appellant
//   Reactive (we defend)  : defendant, respondent
//   Third Party / Other   : third_party, other
//
// Legacy 'court' / 'both' are no longer accepted (mig 112 backfills
// existing rows to NULL); callers that still send them get a clear
// validation error rather than a constraint violation.
func validateOurSide(s string) error {
	switch strings.TrimSpace(s) {
	case "",
		"claimant", "defendant",
		"applicant", "appellant",
		"respondent",
		"third_party", "other":
		return nil
	}
	return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s)
}

// validateInstanceLevel checks the procedural-instance enum (Phase 3
// Slice 8, t-paliad-189, design §7). Empty string clears the column;
// the three named values map to the rule-corpus ladder de.inf.lg →
// de.inf.olg → de.inf.bgh that the SmartTimeline will surface in a
// follow-up calculator slice. The DB-level CHECK on mig 080 enforces
// the same set; this validation gives a clearer error than letting
// the trigger fire.
func validateInstanceLevel(s string) error {
	switch strings.TrimSpace(s) {
	case "", "first", "appeal", "cassation":
		return nil
	}
	return fmt.Errorf("%w: invalid instance_level %q (allowed: first | appeal | cassation | <empty>)",
		ErrInvalidInput, s)
}

// nullableInstanceLevel returns nil for an empty / whitespace value so
// the SQL driver writes NULL, otherwise the trimmed string. Mirrors
// nullableOurSide.
func nullableInstanceLevel(p *string) any {
	if p == nil {
		return nil
	}
	s := strings.TrimSpace(*p)
	if s == "" {
		return nil
	}
	return s
}

// nullableTrimmed returns nil for an empty / whitespace value so the SQL
// driver writes NULL, otherwise the trimmed string. Used for nullable
// text columns whose constraints reject the empty string (e.g.
// projekte_client_number_check requires NULL or 6 digits — a blank
// client_number field on the project form would otherwise fail the
// constraint instead of being treated as "not set").
func nullableTrimmed(p *string) any {
	if p == nil {
		return nil
	}
	s := strings.TrimSpace(*p)
	if s == "" {
		return nil
	}
	return s
}

// nullableOurSide returns nil for an empty / whitespace value so the
// SQL driver writes NULL, otherwise the trimmed string. Mirrors the
// Update payload contract: empty string from the form clears the
// column, a value sets it.
func nullableOurSide(p *string) any {
	if p == nil {
		return nil
	}
	v := strings.TrimSpace(*p)
	if v == "" {
		return nil
	}
	return v
}

// opponentCodePattern matches the slug shape enforced by the
// projects_opponent_code_check constraint (mig 113): uppercase letters,
// digits, dashes, 1-16 chars. The DB CHECK is the source of truth; this
// helper surfaces a friendlier ErrInvalidInput error before the write.
var opponentCodePattern = regexp.MustCompile(`^[A-Z0-9-]{1,16}$`)

// validateOpponentCode checks the litigation-only opponent_code slug
// (t-paliad-222 / m/paliad#50). Empty string clears the column; a
// non-empty value must match opponentCodePattern AND the row must be
// type='litigation' (the DB CHECK enforces this pairing).
//
// projectType may be empty when the caller is doing a partial Update
// against the current row's type — in that case we skip the type gate
// (the Update layer passes current.Type instead, which always has it).
func validateOpponentCode(s, projectType string) error {
	v := strings.TrimSpace(s)
	if v == "" {
		return nil
	}
	if projectType != "" && projectType != "litigation" {
		return fmt.Errorf("%w: opponent_code only valid on type=litigation (got %q)",
			ErrInvalidInput, projectType)
	}
	if !opponentCodePattern.MatchString(v) {
		return fmt.Errorf("%w: invalid opponent_code %q (allowed: %s)",
			ErrInvalidInput, s, "[A-Z0-9-]{1,16}")
	}
	return nil
}

// nullableOpponentCode mirrors nullableOurSide for opponent_code: nil
// or empty/whitespace → SQL NULL; otherwise the trimmed slug.
func nullableOpponentCode(p *string) any {
	if p == nil {
		return nil
	}
	v := strings.TrimSpace(*p)
	if v == "" {
		return nil
	}
	return v
}

func sortByOrder(xs []models.Project, order map[uuid.UUID]int) {
	// Insertion sort — ancestor lists are short (<20).
	for i := 1; i < len(xs); i++ {
		for j := i; j > 0 && order[xs[j].ID] < order[xs[j-1].ID]; j-- {
			xs[j], xs[j-1] = xs[j-1], xs[j]
		}
	}
}
