package services

// ApprovalService implements the 4-Augen-Prüfung workflow on
// paliad.deadlines and paliad.appointments (t-paliad-138).
//
// Architecture: write-then-approve (m's Q5 choice). The mutation lands on
// the entity row immediately; the entity carries approval_status='pending'
// + pending_request_id until an approver flips it to 'approved'. Delete is
// the one stage-then-write exception — we mark the row pending instead of
// hard-deleting, then hard-delete on approve / restore on reject.
//
// Submission entry points (Submit{Create,Update,Complete,Delete}) are
// invoked by DeadlineService / AppointmentService inside their existing
// transactions. They:
//   1. Look up the policy for (project, entity_type, lifecycle_event).
//   2. If no policy → no-op (entity stays approval_status='approved').
//   3. If policy → run a deadlock check (qualified approver != requester
//      must exist), insert an approval_requests row, mark the entity
//      pending, emit a *_approval_requested project_events row.
//
// Decision entry points (Approve / Reject / Revoke) run their own tx and:
//   - Approve: validate canApprove(caller, request); flip the entity back
//     to approved (or hard-delete for delete-lifecycle); emit
//     *_approval_approved.
//   - Reject: validate canApprove; revert the entity from pre_image (or
//     hard-delete a pending-create); emit *_approval_rejected.
//   - Revoke: validate caller == requester; same revert as Reject; emit
//     *_approval_revoked.
//
// Self-approval is blocked at three layers:
//   1. canApprove() returns ErrSelfApproval when caller == requester.
//   2. The DB CHECK constraint approval_requests_no_self_approval refuses
//      decided_by == requested_by writes.
//   3. The deadlock-check excludes the requester from the qualified-approver
//      pool, so the deadlock path can't be silently bypassed.

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

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

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

// ApprovalService is the workflow orchestrator. It holds no entity-specific
// knowledge — DeadlineService / AppointmentService call its Submit*
// methods, and the Approve / Reject / Revoke paths run direct SQL on the
// entity tables to keep the dependency graph acyclic.
type ApprovalService struct {
	db    *sqlx.DB
	users *UserService
}

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

// LookupPolicy returns the effective approval policy for the given tuple,
// or nil if none applies. Reads inside the same tx as Submit* so policy
// reads see whatever the calling tx may have already written.
//
// Resolution (t-paliad-160): delegates to paliad.approval_policy_effective(),
// which returns at most one row after the most-strict-wins fold over the
// project-row / ancestor-row / unit-default candidates. The split-grammar
// columns are:
//
//   - requires_approval — the gate (OR across candidates).
//   - min_role          — the seniority threshold (MAX along the role
//                         ladder among the requires_approval=true
//                         candidates). NULL when the gate is off.
//
// When the gate is off (requires_approval=false OR no candidates), this
// returns nil and the caller skips creating an approval_request entirely.
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
	var row struct {
		RequiresApproval bool           `db:"requires_approval"`
		MinRole          sql.NullString `db:"min_role"`
		Source           sql.NullString `db:"source"`
		SourceID         *uuid.UUID     `db:"source_id"`
	}
	q := `SELECT requires_approval, min_role, source, source_id
	        FROM paliad.approval_policy_effective($1, $2, $3)`
	if err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, nil // no candidates → no policy applies
		}
		return nil, fmt.Errorf("lookup approval policy: %w", err)
	}
	if !row.RequiresApproval || !row.MinRole.Valid {
		return nil, nil // gate off — no approval request needed
	}
	pid := projectID
	return &models.ApprovalPolicy{
		ProjectID:        &pid,
		EntityType:       entityType,
		LifecycleEvent:   lifecycleEvent,
		RequiresApproval: true,
		MinRole:          &row.MinRole.String,
	}, nil
}

// hasQualifiedApprover counts users on the project's team-membership path
// (direct OR ancestor) whose (profession, responsibility) tuple meets the
// strict-ladder threshold, plus any global_admin user, plus any partner-
// unit-derived member where the attachment grants authority (t-paliad-139).
// Excludes requesterID.
//
// Returns true if at least one such user exists. The path-walk JOIN matches
// the visibility predicate so an ancestor partner qualifies for a
// descendant's approval, just like they have visibility.
//
// t-paliad-148: peer authority requires BOTH a profession with sufficient
// level AND a responsibility ∈ {lead, member} that opens the gate.
// observer/external rows are excluded even if the user's profession would
// otherwise qualify — that's the point of the project-level gate.
func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx, projectID, requesterID uuid.UUID, requiredRole string) (bool, error) {
	q := `WITH path AS (
	        SELECT string_to_array(p.path, '.')::uuid[] AS ids
	          FROM paliad.projects p WHERE p.id = $1
	      )
	      SELECT EXISTS (
	          SELECT 1 FROM paliad.project_teams pt
	            JOIN paliad.users u ON u.id = pt.user_id
	            JOIN path ON pt.project_id = ANY(path.ids)
	           WHERE pt.user_id <> $2
	             AND pt.responsibility IN ('lead', 'member')
	             AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
	          UNION ALL
	          SELECT 1 FROM paliad.users u
	           WHERE u.global_role = 'global_admin' AND u.id <> $2
	          UNION ALL
	          SELECT 1 FROM paliad.project_partner_units ppu
	            JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
	            JOIN path ON ppu.project_id = ANY(path.ids)
	           WHERE pum.user_id <> $2
	             AND ppu.derive_grants_authority = true
	             AND pum.unit_role = ANY(ppu.derive_unit_roles)
	             AND paliad.approval_role_level(
	                     paliad.approval_role_from_unit_role(pum.unit_role)
	                 ) >= paliad.approval_role_level($3)
	          LIMIT 1
	      ) AS ok`
	var ok bool
	if err := txOrDB(tx, s.db).GetContext(ctx, &ok, q, projectID, requesterID, requiredRole); err != nil {
		return false, fmt.Errorf("deadlock check: %w", err)
	}
	return ok, nil
}

// SubmitCreate is invoked by Deadline/AppointmentService inside their
// create-tx, after the entity row has been INSERTed but before the
// commit. If a (project, entity_type, 'create') policy applies, it inserts
// the approval_requests row, marks the entity pending, and emits the
// *_approval_requested audit event.
//
// payload is the just-inserted entity's field values (used as audit echo).
//
// Returns the new request ID if pending, nil if no policy applied.
func (s *ApprovalService) SubmitCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
	return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload, nil)
}

// SubmitAgentCreate is the agent-drafted variant of SubmitCreate
// (t-paliad-161). Used when Paliadin drafts a row on the user's behalf —
// the request is created UNCONDITIONALLY (even when no policy applies),
// stamped with requester_kind='agent', and linked to the originating
// paliadin_turns row via agent_turn_id.
//
// The unconditional gate is by design: every agent suggestion needs the
// user's eye (m's lock-in for Q11). Bypassing the policy lookup keeps a
// single audit shape — agent-drafted entities never appear "live" in the
// approved column without an approve-decision behind them, regardless of
// whether the project would have skipped 4-eye for a direct user create.
func (s *ApprovalService) SubmitAgentCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID, agentTurnID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
	return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload, &agentTurnID)
}

// SubmitUpdate is invoked after the entity row has been UPDATEd. preImage
// carries the date-bearing fields that were just overwritten (per Q4
// allowlist) so a rejection can restore them. payload echoes the new values.
func (s *ApprovalService) SubmitUpdate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
	if len(preImage) == 0 {
		// Nothing in the date-bearing allowlist actually changed — bypass
		// the approval flow entirely (the underlying UPDATE was cosmetic).
		return nil, nil
	}
	return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleUpdate, preImage, payload, nil)
}

// SubmitComplete is invoked after status was flipped to 'completed'
// (deadline) or completed_at was set (appointment). preImage stores the
// pre-completion state so a rejection can revert.
func (s *ApprovalService) SubmitComplete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
	return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleComplete, preImage, payload, nil)
}

// SubmitDelete is invoked WITHOUT a prior delete on the entity (delete is
// the stage-then-write exception). The entity row stays alive with
// approval_status='pending'; on approve we hard-delete, on reject we just
// clear the pending markers.
//
// preImage stores the full row state so the inbox can render
// "About to delete: Frist X (due 2026-05-12)".
func (s *ApprovalService) SubmitDelete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage map[string]any) (*uuid.UUID, error) {
	return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleDelete, preImage, nil, nil)
}

// submit is the shared lifecycle-handling kernel.
//
// agentTurnID, when non-nil, marks the request as agent-drafted: the
// request gets requester_kind='agent' + agent_turn_id=<id>, and the
// policy gate is BYPASSED — every agent suggestion goes into pending
// (m's lock-in for t-paliad-161 Q11). When nil, the standard flow runs
// (look up policy, return nil + no-op when no policy applies).
func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType, lifecycle string, preImage, payload map[string]any, agentTurnID *uuid.UUID) (*uuid.UUID, error) {
	// Resolve required role:
	//   - User path: lookup policy, no-op if none, error if no qualified
	//     approver exists for the threshold.
	//   - Agent path: bypass policy lookup; required_role defaults to the
	//     associate floor (the conservative seed across paliad's policy
	//     baseline) so the inbox query for who-can-approve still has a
	//     non-NULL threshold to fold into the strict ladder. The user
	//     receiving the suggestion to approve is themselves and they're
	//     not in the qualified-approver pool (CHECK decided_by != requested_by),
	//     so the deadlock check needs to verify someone OTHER than the
	//     suggesting user can approve — which they can, because the
	//     suggesting user is m (the only Paliadin owner today) and any
	//     project lead / global_admin not equal to m qualifies.
	var requiredRole string
	if agentTurnID == nil {
		policy, err := s.LookupPolicy(ctx, tx, projectID, entityType, lifecycle)
		if err != nil {
			return nil, err
		}
		if policy == nil {
			// No policy applies — entity stays approval_status='approved'. No-op.
			return nil, nil
		}
		// LookupPolicy guarantees MinRole is non-nil whenever a non-nil
		// policy is returned (gate on + threshold set).
		requiredRole = *policy.MinRole
	} else {
		// Agent path: associate threshold (the firm-wide seed baseline).
		requiredRole = "associate"
	}

	// Deadlock check: somebody other than the requester must be qualified
	// to approve, either via project team membership or as global_admin.
	ok, err := s.hasQualifiedApprover(ctx, tx, projectID, requesterID, requiredRole)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, requiredRole)
	}

	// Concurrent-pending guard: the entity table has a CHECK / NOT NULL
	// guard against double-pending — but we surface a clean error rather
	// than letting the UPDATE silently fail. The guard relies on
	// approval_status='approved' being the precondition for a fresh
	// pending state.
	currentStatus, err := s.entityApprovalStatus(ctx, tx, entityType, entityID)
	if err != nil {
		return nil, err
	}
	if currentStatus == ApprovalStatusPending {
		return nil, ErrConcurrentPending
	}

	requestID := uuid.New()
	preImageJSON, err := marshalJSONOrNull(preImage)
	if err != nil {
		return nil, fmt.Errorf("marshal pre_image: %w", err)
	}
	payloadJSON, err := marshalJSONOrNull(payload)
	if err != nil {
		return nil, fmt.Errorf("marshal payload: %w", err)
	}

	// Agent fields are nil on the user path, set on the agent path. The
	// xor-check on approval_requests_agent_xor enforces that
	// requester_kind='agent' implies agent_turn_id IS NOT NULL and vice
	// versa, so we always set both columns coherently here.
	requesterKind := "user"
	var agentTurnArg any
	if agentTurnID != nil {
		requesterKind = "agent"
		agentTurnArg = *agentTurnID
	}

	insertReqSQL := `INSERT INTO paliad.approval_requests
	    (id, project_id, entity_type, entity_id, lifecycle_event,
	     pre_image, payload, requested_by, required_role, status,
	     requester_kind, agent_turn_id)
	  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11)`
	if _, err := tx.ExecContext(ctx, insertReqSQL,
		requestID, projectID, entityType, entityID, lifecycle,
		preImageJSON, payloadJSON, requesterID, requiredRole,
		requesterKind, agentTurnArg); err != nil {
		return nil, fmt.Errorf("insert approval_request: %w", err)
	}

	// Mark the entity row pending. The WHERE approval_status='approved'
	// (or 'legacy') guard makes the UPDATE atomic vs concurrent pending.
	updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
	    SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
	  WHERE id = $2 AND approval_status IN ('approved','legacy')`,
		entityTableName(entityType))
	res, err := tx.ExecContext(ctx, updateEntitySQL, requestID, entityID)
	if err != nil {
		return nil, fmt.Errorf("mark entity pending: %w", err)
	}
	rows, _ := res.RowsAffected()
	if rows != 1 {
		// Either the entity vanished or another tx flipped it pending.
		return nil, ErrConcurrentPending
	}

	// Audit emit.
	eventType := approvalEventType(entityType, "requested")
	descPtr := approvalDescription("requested", requiredRole, lifecycle)
	meta := map[string]any{
		"approval_request_id": requestID.String(),
		"lifecycle_event":     lifecycle,
		"required_role":       requiredRole,
		entityType + "_id":    entityID.String(),
		"requester_kind":      requesterKind,
	}
	if agentTurnID != nil {
		meta["agent_turn_id"] = agentTurnID.String()
	}
	if err := insertProjectEventWithMeta(ctx, tx, projectID, requesterID, eventType, eventType, descPtr, meta); err != nil {
		return nil, err
	}
	return &requestID, nil
}

// Approve flips a pending request to 'approved' and applies the lifecycle
// to the entity. Runs in its own transaction.
func (s *ApprovalService) Approve(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
	return s.decide(ctx, requestID, callerID, RequestStatusApproved, note)
}

// Reject flips a pending request to 'rejected' and reverts the entity from
// pre_image. Runs in its own transaction.
func (s *ApprovalService) Reject(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
	return s.decide(ctx, requestID, callerID, RequestStatusRejected, note)
}

// Revoke is invoked by the requester to undo their own pending submission
// before any approver acts on it. The entity reverts as if the request had
// been rejected, but the request status is 'revoked'. Runs in its own tx.
func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.UUID) error {
	return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
}

// SuggestChanges is the fourth approval action (t-paliad-216). The caller
// proposes a counter-payload + optional free-text note; in one transaction
// we close the old request as 'changes_requested', revert the entity from
// pre_image, then immediately spawn a NEW 'pending' approval_request
// authored by the caller carrying counter_payload as the new payload. The
// new row enters the normal pending flow — anyone eligible (including the
// original requester) can approve, reject, or suggest changes back on it.
// 4-Augen still holds: the suggesting caller is now the new row's
// requested_by, so self-approval is blocked by the standard 3-layer guard.
//
// Authorization is the same as Approve/Reject on the OLD row (canApprove).
// The new row's deadlock check (qualified-approver-exists-other-than-
// caller) runs before the new INSERT so we never spawn an unapprovable
// request.
//
// counterPayload must differ from the old row's payload OR a non-empty
// note must be present — a no-op suggestion (same values, no note) is
// indistinguishable from "I have no opinion" and is rejected with
// ErrSuggestionRequiresChange. counterPayload field shape is the same
// allowlist used by Submit*/applyRevert (the date-bearing columns per
// entity_type); unknown keys are silently dropped at apply time.
//
// SuggestChanges is only valid for lifecycle in (update, complete). For
// create the original entity would be deleted by applyRevert, leaving no
// row to apply a counter to. For delete the original is "remove this
// entity" — a counter-proposal would be a different lifecycle entirely.
// Both return ErrSuggestionLifecycleInvalid; the caller (handler) maps
// it to 400.
//
// Returns the new request ID on success.
func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerID uuid.UUID, counterPayload map[string]any, note string) (*uuid.UUID, error) {
	trimmedNote := strings.TrimSpace(note)

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

	old, err := s.getRequestForUpdate(ctx, tx, requestID)
	if err != nil {
		return nil, err
	}
	if old.Status != RequestStatusPending {
		return nil, fmt.Errorf("%w: status=%s", ErrRequestNotPending, old.Status)
	}
	if old.LifecycleEvent != LifecycleUpdate && old.LifecycleEvent != LifecycleComplete {
		return nil, fmt.Errorf("%w: lifecycle=%s", ErrSuggestionLifecycleInvalid, old.LifecycleEvent)
	}

	// No-op guard: counter must differ from old.payload OR note must be present.
	payloadDiffers, err := payloadsDiffer(old.Payload, counterPayload)
	if err != nil {
		return nil, err
	}
	if !payloadDiffers && trimmedNote == "" {
		return nil, ErrSuggestionRequiresChange
	}

	// Authorization on the OLD row: caller must satisfy canApprove (same
	// gate as Approve/Reject). Self-approval blocks here too.
	decisionKind, err := s.canApprove(ctx, tx, callerID, old)
	if err != nil {
		return nil, err
	}

	now := time.Now().UTC()
	counterJSON, err := marshalJSONOrNull(counterPayload)
	if err != nil {
		return nil, fmt.Errorf("marshal counter_payload: %w", err)
	}

	// Validate counter has at least one counter-allowlisted field for the
	// entity type — otherwise the entity-update below would be a no-op
	// and the new row would just resubmit the SAME values, which is a
	// degenerate case we should reject cleanly. Only run this check when
	// the payload "differs" (i.e. caller actually provided something).
	// Note: validates against the WIDER counter-allowlist (t-paliad-217
	// Slice B), not the date-only revert-allowlist.
	if payloadDiffers {
		if _, _, err := buildCounterSetClauses(old.EntityType, counterPayload); err != nil {
			// buildCounterSetClauses already wraps ErrSuggestionRequiresChange
			// for the "no allowlisted fields" + empty-title cases. Propagate.
			return nil, err
		}
	}

	// 1. Close the OLD row as changes_requested.
	var noteArg any
	if trimmedNote != "" {
		noteArg = trimmedNote
	}
	updateOldSQL := `UPDATE paliad.approval_requests
	    SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
	        decision_note = $5, counter_payload = $6, updated_at = $3
	  WHERE id = $7`
	if _, err := tx.ExecContext(ctx, updateOldSQL,
		RequestStatusChangesRequested, callerID, now, decisionKind,
		noteArg, counterJSON, requestID); err != nil {
		return nil, fmt.Errorf("close old request: %w", err)
	}

	// 2. Revert the entity from old.pre_image (same as Reject).
	if err := s.applyRevert(ctx, tx, old); err != nil {
		return nil, err
	}

	// 3. Deadlock check on the NEW row: someone other than the caller
	//    must be qualified to approve. Original requester is no longer
	//    excluded (they're a regular team member now from the new row's
	//    POV), so they count if their role is sufficient.
	ok, err := s.hasQualifiedApprover(ctx, tx, old.ProjectID, callerID, old.RequiredRole)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, old.RequiredRole)
	}

	// 4. Re-apply the counter_payload to the entity row (write-then-approve).
	//    Reuses buildRevertSetClauses (date-allowlist translation). Always
	//    runs because we validated payloadDiffers + a valid set of keys
	//    above; even when only a note was provided (payloadDiffers=false),
	//    the original payload is re-applied for symmetry with Submit*.
	applyPayload := counterPayload
	if !payloadDiffers {
		// Counter is identical to original — resubmit the same values as
		// the new row's payload so the standard Submit* shape holds.
		if err := json.Unmarshal(old.Payload, &applyPayload); err != nil {
			return nil, fmt.Errorf("unmarshal original payload: %w", err)
		}
	}
	if err := s.applyEntityUpdate(ctx, tx, old.EntityType, old.EntityID, applyPayload); err != nil {
		return nil, err
	}

	// 5. INSERT the NEW pending row, authored by the caller, with
	//    previous_request_id pointing back at the old row.
	newID := uuid.New()
	applyPayloadJSON, err := marshalJSONOrNull(applyPayload)
	if err != nil {
		return nil, fmt.Errorf("marshal new payload: %w", err)
	}
	insertNewSQL := `INSERT INTO paliad.approval_requests
	    (id, project_id, entity_type, entity_id, lifecycle_event,
	     pre_image, payload, requested_by, required_role, status,
	     requester_kind, agent_turn_id, previous_request_id)
	  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', 'user', NULL, $10)`
	if _, err := tx.ExecContext(ctx, insertNewSQL,
		newID, old.ProjectID, old.EntityType, old.EntityID, old.LifecycleEvent,
		[]byte(old.PreImage), applyPayloadJSON, callerID, old.RequiredRole,
		requestID); err != nil {
		return nil, fmt.Errorf("insert new approval_request: %w", err)
	}

	// 6. Mark the entity pending pointing at the new row.
	updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
	    SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
	  WHERE id = $2 AND approval_status IN ('approved','legacy')`,
		entityTableName(old.EntityType))
	res, err := tx.ExecContext(ctx, updateEntitySQL, newID, old.EntityID)
	if err != nil {
		return nil, fmt.Errorf("mark entity pending: %w", err)
	}
	rows, _ := res.RowsAffected()
	if rows != 1 {
		return nil, ErrConcurrentPending
	}

	// 7. Emit *_approval_changes_suggested for the OLD row's transition.
	suggestedEvent := approvalEventType(old.EntityType, "changes_suggested")
	suggestedDesc := approvalDescription("changes_suggested", old.RequiredRole, old.LifecycleEvent)
	suggestedMeta := map[string]any{
		"approval_request_id": requestID.String(),
		"new_request_id":      newID.String(),
		"lifecycle_event":     old.LifecycleEvent,
		"decision_kind":       decisionKind,
		old.EntityType + "_id": old.EntityID.String(),
	}
	if trimmedNote != "" {
		suggestedMeta["decision_note"] = trimmedNote
	}
	if counterJSON != nil {
		suggestedMeta["counter_payload"] = json.RawMessage(counterJSON)
	}
	if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, suggestedEvent, suggestedEvent, suggestedDesc, suggestedMeta); err != nil {
		return nil, err
	}

	// 8. Emit *_approval_requested for the NEW row (same shape as Submit*).
	requestedEvent := approvalEventType(old.EntityType, "requested")
	requestedDesc := approvalDescription("requested", old.RequiredRole, old.LifecycleEvent)
	requestedMeta := map[string]any{
		"approval_request_id":  newID.String(),
		"previous_request_id":  requestID.String(),
		"lifecycle_event":      old.LifecycleEvent,
		"required_role":        old.RequiredRole,
		"requester_kind":       "user",
		old.EntityType + "_id": old.EntityID.String(),
	}
	if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, requestedEvent, requestedEvent, requestedDesc, requestedMeta); err != nil {
		return nil, err
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit: %w", err)
	}
	return &newID, nil
}

// applyEntityUpdate writes the counter_payload fields onto the entity
// row (t-paliad-217 Slice B). Uses the WIDER counter-allowlist
// (buildCounterSetClauses) — every editable field on the entity, not
// just the date-allowlist that triggers approval. Handles
// event_type_ids as a junction-table rewrite when present in payload.
func (s *ApprovalService) applyEntityUpdate(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID, payload map[string]any) error {
	if len(payload) == 0 {
		return fmt.Errorf("%w: empty payload", ErrSuggestionRequiresChange)
	}

	// 1. Column-level updates via the counter-allowlist.
	setClauses, args, err := buildCounterSetClauses(entityType, payload)
	if err != nil {
		return err
	}
	if len(setClauses) > 0 {
		setClauses = append(setClauses, "updated_at = now()")
		args = append(args, entityID)
		q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
			entityTableName(entityType), strings.Join(setClauses, ", "), len(args))
		if _, err := tx.ExecContext(ctx, q, args...); err != nil {
			return fmt.Errorf("apply counter payload to entity: %w", err)
		}
	}

	// 2. event_type_ids junction rewrite (deadline only).
	if entityType == EntityTypeDeadline {
		if raw, ok := payload["event_type_ids"]; ok {
			ids, err := parseUUIDList(raw)
			if err != nil {
				return fmt.Errorf("%w: invalid event_type_ids: %v", ErrSuggestionRequiresChange, err)
			}
			if err := rewriteDeadlineEventTypes(ctx, tx, entityID, ids); err != nil {
				return err
			}
		}
	}
	return nil
}

// parseUUIDList accepts either []any (from json.Unmarshal of a JSON
// array) or []string and returns a []uuid.UUID. Empty list = explicit
// clear; nil-typed list also empty.
func parseUUIDList(raw any) ([]uuid.UUID, error) {
	if raw == nil {
		return nil, nil
	}
	arr, ok := raw.([]any)
	if !ok {
		// Fallback: caller serialized as []string directly.
		if sarr, ok := raw.([]string); ok {
			out := make([]uuid.UUID, 0, len(sarr))
			for _, s := range sarr {
				id, err := uuid.Parse(s)
				if err != nil {
					return nil, fmt.Errorf("not a UUID: %q", s)
				}
				out = append(out, id)
			}
			return out, nil
		}
		return nil, fmt.Errorf("expected array, got %T", raw)
	}
	out := make([]uuid.UUID, 0, len(arr))
	for _, v := range arr {
		s, ok := v.(string)
		if !ok {
			return nil, fmt.Errorf("expected string in array, got %T", v)
		}
		id, err := uuid.Parse(s)
		if err != nil {
			return nil, fmt.Errorf("not a UUID: %q", s)
		}
		out = append(out, id)
	}
	return out, nil
}

// payloadsDiffer returns true iff the candidate counter map decodes to a
// value that differs from the old row's payload jsonb. Used by
// SuggestChanges to detect "no-op suggestion". Both NULL or both empty
// map = identical → false. Comparison is by canonical re-marshal so
// jsonb-key-ordering doesn't poison the equality check.
func payloadsDiffer(old models.NullableJSON, candidate map[string]any) (bool, error) {
	if len(candidate) == 0 && len(old) == 0 {
		return false, nil
	}
	if len(candidate) == 0 || len(old) == 0 {
		return true, nil
	}
	var oldMap map[string]any
	if err := json.Unmarshal(old, &oldMap); err != nil {
		return false, fmt.Errorf("unmarshal old payload: %w", err)
	}
	oldCanonical, err := json.Marshal(oldMap)
	if err != nil {
		return false, fmt.Errorf("re-marshal old payload: %w", err)
	}
	candCanonical, err := json.Marshal(candidate)
	if err != nil {
		return false, fmt.Errorf("marshal candidate payload: %w", err)
	}
	return !bytes.Equal(oldCanonical, candCanonical), nil
}

// decide is the shared kernel for Approve / Reject / Revoke. The decision
// kind is derived from the (caller, request) relationship and the requested
// final status:
//   - RequestStatusApproved: caller must NOT be requester; admin override or peer.
//   - RequestStatusRejected: same authorization rules as Approve.
//   - RequestStatusRevoked:  caller MUST be requester.
func (s *ApprovalService) decide(ctx context.Context, requestID, callerID uuid.UUID, finalStatus, note string) error {
	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	req, err := s.getRequestForUpdate(ctx, tx, requestID)
	if err != nil {
		return err
	}
	if req.Status != RequestStatusPending {
		return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
	}

	var decisionKind string
	switch finalStatus {
	case RequestStatusApproved, RequestStatusRejected:
		kind, err := s.canApprove(ctx, tx, callerID, req)
		if err != nil {
			return err
		}
		decisionKind = kind
	case RequestStatusRevoked:
		if callerID != req.RequestedBy {
			return ErrNotApprover
		}
		decisionKind = DecisionKindPeer // unused for revoke but keeps non-NULL audit
	default:
		return fmt.Errorf("invalid final status %q", finalStatus)
	}

	// Apply the lifecycle outcome to the entity.
	switch finalStatus {
	case RequestStatusApproved:
		if err := s.applyApproved(ctx, tx, req, callerID); err != nil {
			return err
		}
	case RequestStatusRejected, RequestStatusRevoked:
		if err := s.applyRevert(ctx, tx, req); err != nil {
			return err
		}
	}

	// Update the request row.
	now := time.Now().UTC()
	var trimmedNote *string
	if n := strings.TrimSpace(note); n != "" {
		trimmedNote = &n
	}
	updateReqSQL := `UPDATE paliad.approval_requests
	    SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
	        decision_note = $5, updated_at = $3
	  WHERE id = $6`
	// For revoke, decided_by stays NULL (the requester didn't "decide" to
	// approve, they pulled the request) — but a CHECK (decided_by != requested_by)
	// would block decided_by=requester anyway. NULL is correct.
	var decidedBy any
	var decisionKindArg any
	if finalStatus != RequestStatusRevoked {
		decidedBy = callerID
		decisionKindArg = decisionKind
	} else {
		decidedBy = nil
		decisionKindArg = nil
	}
	if _, err := tx.ExecContext(ctx, updateReqSQL,
		finalStatus, decidedBy, now, decisionKindArg, trimmedNote, requestID); err != nil {
		return fmt.Errorf("update approval_request: %w", err)
	}

	// Audit emit.
	var verlaufKind string
	switch finalStatus {
	case RequestStatusApproved:
		verlaufKind = "approved"
	case RequestStatusRejected:
		verlaufKind = "rejected"
	case RequestStatusRevoked:
		verlaufKind = "revoked"
	}
	eventType := approvalEventType(req.EntityType, verlaufKind)
	descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
	meta := map[string]any{
		"approval_request_id": req.ID.String(),
		"lifecycle_event":     req.LifecycleEvent,
		req.EntityType + "_id": req.EntityID.String(),
	}
	if finalStatus != RequestStatusRevoked {
		meta["decision_kind"] = decisionKind
	}
	if trimmedNote != nil {
		meta["decision_note"] = *trimmedNote
	}
	if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
		return err
	}
	return tx.Commit()
}

// canApprove enforces the strict-ladder gate plus the no-self-approval
// rule. Returns the decision_kind ('peer' | 'admin_override' |
// 'derived_peer') the caller should record, or an error.
//
// Resolution order (t-paliad-139 §4.2):
//  1. Self-approval is hard-blocked.
//  2. global_admin always wins ('admin_override').
//  3. Direct or ancestor project_teams membership with sufficient role
//     ('peer').
//  4. Partner-unit-derived membership with derive_grants_authority=true
//     and a unit_role that maps (via approval_role_from_unit_role) to a
//     project_role with sufficient level ('derived_peer').
func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID uuid.UUID, req *models.ApprovalRequest) (string, error) {
	if callerID == req.RequestedBy {
		return "", ErrSelfApproval
	}
	user, err := s.users.GetByID(ctx, callerID)
	if err != nil {
		return "", err
	}
	if user == nil {
		return "", ErrNotApprover
	}
	if user.GlobalRole == "global_admin" {
		return DecisionKindAdminOverride, nil
	}
	// Path-walk: check direct OR ancestor team membership with a
	// responsibility that opens the gate (lead/member) AND a profession
	// whose level meets the threshold (t-paliad-148 tuple-with-gate).
	q := `SELECT EXISTS (
	    SELECT 1 FROM paliad.project_teams pt
	      JOIN paliad.users u ON u.id = pt.user_id
	     WHERE pt.user_id = $1
	       AND pt.project_id = ANY(string_to_array(
	           (SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
	       AND pt.responsibility IN ('lead', 'member')
	       AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
	)`
	var ok bool
	if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
		return "", fmt.Errorf("authorization check: %w", err)
	}
	if ok {
		return DecisionKindPeer, nil
	}
	// t-paliad-139 derivation branch: check authority-granting partner-unit
	// attachments on the project's path.
	qDerived := `SELECT EXISTS (
	    SELECT 1 FROM paliad.project_partner_units ppu
	      JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
	     WHERE pum.user_id = $1
	       AND ppu.project_id = ANY(string_to_array(
	           (SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
	       AND ppu.derive_grants_authority = true
	       AND pum.unit_role = ANY(ppu.derive_unit_roles)
	       AND paliad.approval_role_level(
	               paliad.approval_role_from_unit_role(pum.unit_role)
	           ) >= paliad.approval_role_level($3)
	)`
	var derivedOK bool
	if err := tx.GetContext(ctx, &derivedOK, qDerived, callerID, req.ProjectID, req.RequiredRole); err != nil {
		return "", fmt.Errorf("derived authorization check: %w", err)
	}
	if derivedOK {
		return DecisionKindDerivedPeer, nil
	}
	return "", ErrNotApprover
}

// applyApproved finalises the lifecycle on the entity row.
func (s *ApprovalService) applyApproved(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest, approverID uuid.UUID) error {
	table := entityTableName(req.EntityType)
	now := time.Now().UTC()

	if req.LifecycleEvent == LifecycleDelete {
		// Hard-delete the entity. The approval_requests.entity_id reference
		// is a polymorphic uuid (no FK) so it survives the row going away.
		// pending_request_id on the entity has ON DELETE SET NULL but the
		// entity is the one being deleted, not the request — so this is
		// just a plain DELETE.
		if _, err := tx.ExecContext(ctx,
			fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
			req.EntityID); err != nil {
			return fmt.Errorf("delete on approve: %w", err)
		}
		return nil
	}

	// Non-delete approve = clear pending markers, set approved_by/at.
	q := fmt.Sprintf(`UPDATE paliad.%s
	    SET approval_status = 'approved',
	        pending_request_id = NULL,
	        approved_by = $1,
	        approved_at = $2,
	        updated_at = $2
	  WHERE id = $3`, table)
	if _, err := tx.ExecContext(ctx, q, approverID, now, req.EntityID); err != nil {
		return fmt.Errorf("clear pending on approve: %w", err)
	}
	return nil
}

// applyRevert undoes the in-flight change on the entity row, restoring it
// from the request's pre_image jsonb. Used by both Reject and Revoke.
func (s *ApprovalService) applyRevert(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest) error {
	table := entityTableName(req.EntityType)

	switch req.LifecycleEvent {
	case LifecycleCreate:
		// The entity should never have existed. Hard-delete.
		if _, err := tx.ExecContext(ctx,
			fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
			req.EntityID); err != nil {
			return fmt.Errorf("delete on reject-create: %w", err)
		}
		return nil

	case LifecycleDelete:
		// We never deleted the entity (delete is stage-then-write); just
		// clear the pending markers so the row is fully alive again.
		q := fmt.Sprintf(`UPDATE paliad.%s
		    SET approval_status = CASE WHEN approval_status = 'pending'
		                                THEN 'approved' ELSE approval_status END,
		        pending_request_id = NULL,
		        updated_at = now()
		  WHERE id = $1`, table)
		if _, err := tx.ExecContext(ctx, q, req.EntityID); err != nil {
			return fmt.Errorf("clear pending on reject-delete: %w", err)
		}
		return nil

	case LifecycleUpdate, LifecycleComplete:
		// Restore pre_image fields, clear pending markers.
		preImage := map[string]any{}
		if len(req.PreImage) > 0 {
			if err := json.Unmarshal(req.PreImage, &preImage); err != nil {
				return fmt.Errorf("unmarshal pre_image: %w", err)
			}
		}
		setClauses, args, err := buildRevertSetClauses(req.EntityType, preImage)
		if err != nil {
			return err
		}
		// Always clear pending markers + revert approval_status.
		setClauses = append(setClauses,
			"approval_status = 'approved'",
			"pending_request_id = NULL",
			"updated_at = now()")
		args = append(args, req.EntityID)
		q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
			table, strings.Join(setClauses, ", "), len(args))
		if _, err := tx.ExecContext(ctx, q, args...); err != nil {
			return fmt.Errorf("revert entity from pre_image: %w", err)
		}
		return nil

	default:
		return fmt.Errorf("%w: lifecycle %q", ErrUnknownEntityType, req.LifecycleEvent)
	}
}

// buildRevertSetClauses translates pre_image jsonb keys into SQL SET
// fragments for the Reject / Revoke path. Only the date-bearing
// t-paliad-138 §Q4 allowlist is honoured; unknown keys are silently
// dropped to defend against malformed pre_image rows (defence-in-depth:
// callers should already be sending only allowlisted fields, but a
// hostile UPDATE on the request row shouldn't let arbitrary fields be
// reverted).
//
// This is intentionally NARROWER than buildCounterSetClauses (which
// handles the SuggestChanges counter-payload). Reject restores ONLY what
// was originally captured in pre_image; SuggestChanges can write any
// counter-allowlist field the approver chose to author.
func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string, []any, error) {
	var setClauses []string
	var args []any

	add := func(col string, val any) {
		args = append(args, val)
		setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
	}

	switch entityType {
	case EntityTypeDeadline:
		for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
			if v, ok := preImage[col]; ok {
				add(col, v)
			}
		}
		// Complete-revert restores status='pending' + completed_at NULL.
		// We detect this branch by the presence of a status key; lifecycle
		// is the formal source but pre_image is what the caller stored.
		if v, ok := preImage["status"]; ok {
			add("status", v)
		}
		if _, ok := preImage["completed_at"]; ok {
			// Always NULL on revert — completion didn't really happen.
			args = append(args, nil)
			setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
		}

	case EntityTypeAppointment:
		for _, col := range []string{"start_at", "end_at"} {
			if v, ok := preImage[col]; ok {
				add(col, v)
			}
		}
		if _, ok := preImage["completed_at"]; ok {
			args = append(args, nil)
			setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
		}

	default:
		return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
	}

	if len(setClauses) == 0 {
		return nil, nil, fmt.Errorf("%w: empty pre_image for %s", ErrUnknownEntityType, entityType)
	}
	return setClauses, args, nil
}

// buildCounterSetClauses translates a SuggestChanges counter_payload jsonb
// into SQL SET fragments for the entity row (t-paliad-217 Slice B). This
// is the WIDER counter-allowlist — m's 2026-05-20 lock-in: every "real"
// editable field on the entity is in scope for a counter-proposal, not
// just the date-allowlist that triggers approval (t-paliad-138 §Q4).
//
// Unknown keys are silently dropped — defence-in-depth against a hostile
// counter_payload making it past the handler's body decode. Returns an
// error iff zero allowlisted fields are present (caller surfaces as
// ErrSuggestionRequiresChange when paired with an empty note).
//
// event_type_ids is NOT a column on paliad.deadlines — it's a junction
// table (paliad.deadline_event_types). applyEntityUpdate handles it
// separately; this function silently ignores the key.
func buildCounterSetClauses(entityType string, counter map[string]any) ([]string, []any, error) {
	var setClauses []string
	var args []any

	add := func(col string, val any) {
		args = append(args, val)
		setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
	}

	// addText accepts string keys and stores either a non-NULL string or
	// NULL when the caller explicitly cleared the value with an empty
	// string. Used for the optional-text columns (description, notes,
	// location, etc.).
	addText := func(col string, raw any) {
		if raw == nil {
			args = append(args, nil)
		} else {
			s, _ := raw.(string)
			if s == "" {
				args = append(args, nil)
			} else {
				args = append(args, s)
			}
		}
		setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
	}

	switch entityType {
	case EntityTypeDeadline:
		// Date allowlist (existing).
		for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
			if v, ok := counter[col]; ok {
				add(col, v)
			}
		}
		// Required text (NOT NULL on the column — refuse empty).
		if v, ok := counter["title"]; ok {
			s, _ := v.(string)
			if strings.TrimSpace(s) == "" {
				return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange)
			}
			add("title", s)
		}
		// Nullable text (empty string clears).
		for _, col := range []string{"description", "notes", "rule_code"} {
			if v, ok := counter[col]; ok {
				addText(col, v)
			}
		}

	case EntityTypeAppointment:
		// Datetime allowlist (existing).
		for _, col := range []string{"start_at", "end_at"} {
			if v, ok := counter[col]; ok {
				add(col, v)
			}
		}
		if v, ok := counter["title"]; ok {
			s, _ := v.(string)
			if strings.TrimSpace(s) == "" {
				return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange)
			}
			add("title", s)
		}
		for _, col := range []string{"description", "location", "appointment_type"} {
			if v, ok := counter[col]; ok {
				addText(col, v)
			}
		}

	default:
		return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
	}

	// event_type_ids is handled outside this function (junction-table
	// write). Its presence alone in the counter doesn't count as "zero
	// fields" — applyEntityUpdate inspects len(setClauses)==0 against the
	// combined picture, not this return value.
	if len(setClauses) == 0 {
		if _, ok := counter["event_type_ids"]; !ok {
			return nil, nil, fmt.Errorf("%w: no allowlisted fields in counter for %s", ErrSuggestionRequiresChange, entityType)
		}
	}
	return setClauses, args, nil
}

// rewriteDeadlineEventTypes replaces the deadline_event_types junction
// rows for a deadline with the provided list (t-paliad-217 Slice B).
// Empty list clears the junction (the deadline has no event-type tags).
// nil list = no-op (caller didn't include event_type_ids in the counter).
//
// We don't validate the event_type ids exist — the FK to paliad.event_types
// catches that with an ON DELETE CASCADE-safe failure. Caller wraps in tx.
func rewriteDeadlineEventTypes(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID, ids []uuid.UUID) error {
	if _, err := tx.ExecContext(ctx,
		`DELETE FROM paliad.deadline_event_types WHERE deadline_id = $1`, deadlineID); err != nil {
		return fmt.Errorf("clear deadline_event_types: %w", err)
	}
	if len(ids) == 0 {
		return nil
	}
	values := make([]string, 0, len(ids))
	args := make([]any, 0, len(ids)+1)
	args = append(args, deadlineID)
	for i, id := range ids {
		values = append(values, fmt.Sprintf("($1, $%d)", i+2))
		args = append(args, id)
	}
	q := `INSERT INTO paliad.deadline_event_types (deadline_id, event_type_id) VALUES ` + strings.Join(values, ", ")
	if _, err := tx.ExecContext(ctx, q, args...); err != nil {
		return fmt.Errorf("insert deadline_event_types: %w", err)
	}
	return nil
}

// getRequestForUpdate locks an approval_requests row inside the tx for
// decision processing.
func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*models.ApprovalRequest, error) {
	var req models.ApprovalRequest
	q := `SELECT id, project_id, entity_type, entity_id, lifecycle_event,
	             pre_image, payload, requested_by, requested_at, required_role,
	             status, decided_by, decided_at, decision_kind, decision_note,
	             requester_kind, agent_turn_id,
	             counter_payload, previous_request_id,
	             created_at, updated_at
	        FROM paliad.approval_requests
	       WHERE id = $1
	         FOR UPDATE`
	if err := tx.GetContext(ctx, &req, q, requestID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrRequestNotPending
		}
		return nil, fmt.Errorf("load request: %w", err)
	}
	return &req, nil
}

// entityApprovalStatus reads the current approval_status on the entity
// row. Returns "" if the row doesn't exist.
func (s *ApprovalService) entityApprovalStatus(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID) (string, error) {
	q := fmt.Sprintf(`SELECT approval_status FROM paliad.%s WHERE id = $1`,
		entityTableName(entityType))
	var status string
	if err := txOrDB(tx, s.db).GetContext(ctx, &status, q, entityID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return "", nil
		}
		return "", fmt.Errorf("read approval_status: %w", err)
	}
	return status, nil
}

// PendingRequestForEntity returns the request_id + required_role of the
// in-flight approval_request for an entity in approval_status='pending'.
// Returns ("", "", nil) when no pending request is associated. Used by
// the entity services to enrich ErrConcurrentPending into a
// PendingApprovalError that handlers can render as a 409 with structured
// payload.
func (s *ApprovalService) PendingRequestForEntity(ctx context.Context, entityType string, entityID uuid.UUID) (string, string, error) {
	q := `SELECT id::text, required_role
	        FROM paliad.approval_requests
	       WHERE entity_type = $1 AND entity_id = $2 AND status = 'pending'
	       ORDER BY requested_at DESC
	       LIMIT 1`
	var row struct {
		ID           string `db:"id"`
		RequiredRole string `db:"required_role"`
	}
	if err := s.db.GetContext(ctx, &row, q, entityType, entityID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return "", "", nil
		}
		return "", "", fmt.Errorf("lookup pending request: %w", err)
	}
	return row.ID, row.RequiredRole, nil
}

// entityTableName resolves the SQL table name for a given entity_type.
// Internal helper — entityType comes from server-side constants, not user
// input, so a panic on an unknown value is a programming error.
func entityTableName(entityType string) string {
	switch entityType {
	case EntityTypeDeadline:
		return "deadlines"
	case EntityTypeAppointment:
		return "appointments"
	default:
		panic(fmt.Sprintf("approval: unknown entity_type %q", entityType))
	}
}

// approvalEventType returns the project_events.event_type value for a
// given (entity, lifecycle-step) pair. Step is one of "requested" |
// "approved" | "rejected" | "revoked".
func approvalEventType(entityType, step string) string {
	return entityType + "_approval_" + step
}

// approvalDescription returns the short audit description string. Frontend
// renders the localized version via translateEvent; this is the raw audit
// row's description column, used as a fallback and for /admin/audit-log.
func approvalDescription(step, requiredRole, lifecycle string) *string {
	d := fmt.Sprintf("%s — %s/%s", step, lifecycle, requiredRole)
	return &d
}

// txOrDB returns the tx if non-nil, else the db. Lets read helpers run
// either inside a calling tx (for consistency with concurrent writes) or
// standalone for List endpoints.
func txOrDB(tx *sqlx.Tx, db *sqlx.DB) sqlxQueryer {
	if tx != nil {
		return tx
	}
	return db
}

// sqlxQueryer is the minimal subset of *sqlx.DB / *sqlx.Tx we need.
// Defined here to avoid adding a public abstraction across the package.
type sqlxQueryer interface {
	GetContext(ctx context.Context, dest any, query string, args ...any) error
	SelectContext(ctx context.Context, dest any, query string, args ...any) error
	QueryRowxContext(ctx context.Context, query string, args ...any) *sqlx.Row
}

// marshalJSONOrNull returns []byte("null") JSON-RawMessage style for
// nil/empty maps so callers can pass it directly to a jsonb column without
// branching at every call site.
func marshalJSONOrNull(m map[string]any) ([]byte, error) {
	if len(m) == 0 {
		return nil, nil
	}
	return json.Marshal(m)
}

// ============================================================================
// Read paths — inbox + policy CRUD.
// ============================================================================

// ApprovalRequestView is the inbox-friendly projection of an approval
// request: the bare ApprovalRequest plus the contextual labels the inbox
// needs to render a row without further fetches.
//
// ViewerCanApprove + ViewerIsRequester are per-viewer eligibility flags
// computed against the $1 callerID bound at query time (t-paliad-202).
// The frontend uses them to grey out the action buttons it knows the
// server would reject, replacing the previous click-then-alert UX.
type ApprovalRequestView struct {
	models.ApprovalRequest
	ProjectTitle      string     `db:"project_title"       json:"project_title"`
	EntityTitle       *string    `db:"entity_title"        json:"entity_title,omitempty"`
	RequesterName     string     `db:"requester_name"      json:"requester_name"`
	RequesterEmail    string     `db:"requester_email"     json:"requester_email"`
	DeciderName       *string    `db:"decider_name"        json:"decider_name,omitempty"`
	DeciderEmail      *string    `db:"decider_email"       json:"decider_email,omitempty"`
	ViewerCanApprove  bool       `db:"viewer_can_approve"  json:"viewer_can_approve"`
	ViewerIsRequester bool       `db:"viewer_is_requester" json:"viewer_is_requester"`
	// NextRequestID is the forward-pointer from a changes_requested row
	// to the new pending row spawned by SuggestChanges (t-paliad-216).
	// Hydrated via correlated subquery on previous_request_id; the
	// partial index approval_requests_previous_idx keeps the lookup O(1).
	// NULL on every row that hasn't been counter-proposed.
	NextRequestID *uuid.UUID `db:"next_request_id"     json:"next_request_id,omitempty"`
}

// approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean
// expression that returns true iff the user bound to $1 is qualified to
// approve the approval_requests row aliased `ar` on the project aliased
// `p` (i.e. the SELECT must include `paliad.approval_requests ar JOIN
// paliad.projects p ON p.id = ar.project_id`). The three eligibility
// branches mirror canApprove (line 484):
//
//   - $1 is global_admin, OR
//   - $1 has direct/ancestor project_teams membership with responsibility
//     ∈ {lead, member} AND a profession at or above the threshold
//     (t-paliad-148 tuple-with-gate), OR
//   - $1 has partner-unit-derived authority (t-paliad-139).
//
// Self-authorship is NOT subtracted here — callers add the
// `ar.requested_by <> $1` predicate when they want the strict
// "can approve" semantics (the inbox WHERE) or fold it into the
// SELECT (viewer_can_approve column). Keeping the two predicates
// separate lets the same fragment serve both ListPendingForApprover's
// filter and the per-row viewer flag without duplicating SQL.
const approvalEligibilitySQL = `(
    EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
    OR EXISTS (
        SELECT 1 FROM paliad.project_teams pt
          JOIN paliad.users u ON u.id = pt.user_id
         WHERE pt.user_id = $1
           AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
           AND pt.responsibility IN ('lead', 'member')
           AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
    )
    OR EXISTS (
        SELECT 1 FROM paliad.project_partner_units ppu
          JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
         WHERE pum.user_id = $1
           AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
           AND ppu.derive_grants_authority = true
           AND pum.unit_role = ANY(ppu.derive_unit_roles)
           AND paliad.approval_role_level(
                   paliad.approval_role_from_unit_role(pum.unit_role)
               ) >= paliad.approval_role_level(ar.required_role)
    )
)`

// approvalRequestViewColumns binds $1 = callerID via the two viewer_*
// flags. Every caller must pass the caller's UUID as the first arg.
const approvalRequestViewColumns = `
    ar.id, ar.project_id, ar.entity_type, ar.entity_id, ar.lifecycle_event,
    ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
    ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
    ar.requester_kind, ar.agent_turn_id,
    ar.counter_payload, ar.previous_request_id,
    ar.created_at, ar.updated_at,
    p.title AS project_title,
    CASE WHEN ar.entity_type = 'deadline'    THEN d.title
         WHEN ar.entity_type = 'appointment' THEN a.title
         END AS entity_title,
    COALESCE(ru.display_name, ru.email) AS requester_name,
    ru.email                            AS requester_email,
    du.display_name                     AS decider_name,
    du.email                            AS decider_email,
    (ar.status = 'pending' AND ar.requested_by <> $1 AND ` + approvalEligibilitySQL + `) AS viewer_can_approve,
    (ar.requested_by = $1)              AS viewer_is_requester,
    (SELECT nxt.id FROM paliad.approval_requests nxt
       WHERE nxt.previous_request_id = ar.id
       ORDER BY nxt.requested_at DESC
       LIMIT 1)                          AS next_request_id`

const approvalRequestViewJoins = `
    paliad.approval_requests ar
    JOIN paliad.projects p ON p.id = ar.project_id
    JOIN paliad.users    ru ON ru.id = ar.requested_by
    LEFT JOIN paliad.users du ON du.id = ar.decided_by
    LEFT JOIN paliad.deadlines    d ON ar.entity_type = 'deadline'    AND d.id = ar.entity_id
    LEFT JOIN paliad.appointments a ON ar.entity_type = 'appointment' AND a.id = ar.entity_id`

// InboxFilter narrows the inbox listings.
type InboxFilter struct {
	Status     string  // "" → no filter; otherwise one of RequestStatus*
	ProjectID  *uuid.UUID
	EntityType string  // "" → both
	Limit      int     // 0 → 100
}

// ListPendingForApprover returns approval requests where the caller is
// qualified to approve and is not the requester.
func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
	limit := filter.Limit
	if limit <= 0 || limit > 200 {
		limit = 100
	}
	conds := []string{
		"ar.status = 'pending'",
		"ar.requested_by <> $1",
		// Eligibility predicate (the three branches mirror canApprove and
		// the viewer_can_approve SELECT expression — same fragment, single
		// source of truth).
		approvalEligibilitySQL,
	}
	args := []any{callerID}
	if filter.ProjectID != nil {
		args = append(args, *filter.ProjectID)
		conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
	}
	if filter.EntityType != "" {
		args = append(args, filter.EntityType)
		conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
	}
	args = append(args, limit)

	q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at ASC LIMIT $%d`,
		approvalRequestViewColumns, approvalRequestViewJoins,
		strings.Join(conds, " AND "), len(args))

	var out []ApprovalRequestView
	if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
		return nil, fmt.Errorf("list pending for approver: %w", err)
	}
	return out, nil
}

// ListSubmittedByUser returns approval requests authored by the caller.
// Status filter optional.
func (s *ApprovalService) ListSubmittedByUser(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
	limit := filter.Limit
	if limit <= 0 || limit > 200 {
		limit = 100
	}
	conds := []string{"ar.requested_by = $1"}
	args := []any{callerID}
	if filter.Status != "" {
		args = append(args, filter.Status)
		conds = append(conds, fmt.Sprintf("ar.status = $%d", len(args)))
	}
	if filter.ProjectID != nil {
		args = append(args, *filter.ProjectID)
		conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
	}
	if filter.EntityType != "" {
		args = append(args, filter.EntityType)
		conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
	}
	args = append(args, limit)

	q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at DESC LIMIT $%d`,
		approvalRequestViewColumns, approvalRequestViewJoins,
		strings.Join(conds, " AND "), len(args))

	var out []ApprovalRequestView
	if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
		return nil, fmt.Errorf("list submitted by user: %w", err)
	}
	return out, nil
}

// GetRequest returns one approval request hydrated for the inbox detail
// view, with viewer_can_approve / viewer_is_requester resolved for
// callerID. Visibility is gated upstream by the handler (anyone with
// project access can see the request).
func (s *ApprovalService) GetRequest(ctx context.Context, callerID, requestID uuid.UUID) (*ApprovalRequestView, error) {
	// $1 = callerID (binds the viewer_* flags); $2 = requestID.
	q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $2`,
		approvalRequestViewColumns, approvalRequestViewJoins)
	var v ApprovalRequestView
	if err := s.db.GetContext(ctx, &v, q, callerID, requestID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, nil
		}
		return nil, fmt.Errorf("get approval request: %w", err)
	}
	return &v, nil
}

// PendingCountForUser returns how many requests await this user's approval.
// Cheap query for the sidebar bell badge.
//
// Eligibility mirrors ListPendingForApprover: global_admin OR direct/
// ancestor project_teams membership with responsibility ∈ {lead, member}
// AND profession meeting the threshold (t-paliad-148) OR partner-unit-
// derived authority (t-paliad-139).
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
	q := `SELECT COUNT(*)
	        FROM paliad.approval_requests ar
	        JOIN paliad.projects p ON p.id = ar.project_id
	       WHERE ar.status = 'pending'
	         AND ar.requested_by <> $1
	         AND ` + approvalEligibilitySQL
	var n int
	if err := s.db.GetContext(ctx, &n, q, callerID); err != nil {
		return 0, fmt.Errorf("pending count: %w", err)
	}
	return n, nil
}

// ============================================================================
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154).
//
// Two scopes coexist:
//
//   - Project rows  (project_id IS NOT NULL, partner_unit_id IS NULL):
//                   the most-specific override for that one project.
//   - Unit defaults (project_id IS NULL, partner_unit_id IS NOT NULL):
//                   firm-wide defaults applied to every project attached
//                   to that partner unit (via paliad.project_partner_units).
//
// XOR enforced by approval_policies_scope_xor in migration 062.
//
// Audit emission: every set / cleared writes one row to paliad.policy_audit_log
// (Q8 of the locked design — surfaces on /admin/audit-log only, never on
// per-project /verlauf). The actor is always a global_admin.
// ============================================================================

// IsValidPolicyRole returns true iff the value is a valid required_role for
// an approval_policies row. Accepts the strict-ladder roles AND the 'none'
// sentinel that suppresses inherited defaults at project-row level. Distinct
// from IsValidRequiredRole, which is used by the gate (and rejects 'none' as
// a level-0 ineligible value).
func IsValidPolicyRole(role string) bool {
	switch role {
	case ProfessionPartner, ProfessionOfCounsel, ProfessionAssociate,
		ProfessionSeniorPA, ProfessionPA, "none":
		return true
	}
	return false
}

// ListProjectPolicies returns the project-specific policy rows for a single
// project (up to 8: deadline×4 + appointment×4). Does NOT include inherited
// rows or unit defaults — those come via GetEffectivePoliciesMatrix.
func (s *ApprovalService) ListProjectPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
	q := `SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
	             requires_approval, min_role,
	             created_at, updated_at, created_by
	        FROM paliad.approval_policies
	       WHERE project_id = $1
	    ORDER BY entity_type, lifecycle_event`
	var out []models.ApprovalPolicy
	if err := s.db.SelectContext(ctx, &out, q, projectID); err != nil {
		return nil, fmt.Errorf("list project approval policies: %w", err)
	}
	return out, nil
}

// ListUnitPolicies returns the unit-default policy rows for a single
// partner unit (up to 8).
func (s *ApprovalService) ListUnitPolicies(ctx context.Context, unitID uuid.UUID) ([]models.ApprovalPolicy, error) {
	q := `SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
	             requires_approval, min_role,
	             created_at, updated_at, created_by
	        FROM paliad.approval_policies
	       WHERE partner_unit_id = $1
	    ORDER BY entity_type, lifecycle_event`
	var out []models.ApprovalPolicy
	if err := s.db.SelectContext(ctx, &out, q, unitID); err != nil {
		return nil, fmt.Errorf("list unit approval policies: %w", err)
	}
	return out, nil
}

// validatePolicyTuple returns ErrInvalidInput if any of the three policy
// strings are out of range for the underlying CHECK constraints.
func validatePolicyTuple(entityType, lifecycle, requiredRole string) error {
	if !IsValidPolicyRole(requiredRole) {
		return fmt.Errorf("%w: required_role %q", ErrInvalidInput, requiredRole)
	}
	if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
		return fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
	}
	switch lifecycle {
	case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
	default:
		return fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
	}
	return nil
}

// validatePolicySplit validates the split-grammar tuple (requires_approval,
// min_role). When requires_approval=true, min_role must be one of the
// strict-ladder professions; when false, min_role must be nil.
func validatePolicySplit(entityType, lifecycle string, requiresApproval bool, minRole *string) error {
	if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
		return fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
	}
	switch lifecycle {
	case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
	default:
		return fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
	}
	if requiresApproval {
		if minRole == nil || !IsValidRequiredRole(*minRole) {
			role := ""
			if minRole != nil {
				role = *minRole
			}
			return fmt.Errorf("%w: min_role %q (required when requires_approval=true)", ErrInvalidInput, role)
		}
	} else if minRole != nil {
		return fmt.Errorf("%w: min_role must be NULL when requires_approval=false", ErrInvalidInput)
	}
	return nil
}

// splitFromLegacy maps the legacy required_role grammar into the
// split-grammar pair. 'none' → (false, nil); else → (true, &role). Used by
// the back-compat Upsert*Policy shims that still take required_role.
func splitFromLegacy(requiredRole string) (bool, *string) {
	if requiredRole == "none" {
		return false, nil
	}
	r := requiredRole
	return true, &r
}

// legacyFromSplit is the inverse: produce the audit-row required_role
// string. Used so the policy_audit_log keeps the human-readable role
// (or 'none') under the old grammar even after callers cut over to the
// split-grammar API.
func legacyFromSplit(requiresApproval bool, minRole *string) string {
	if !requiresApproval || minRole == nil {
		return "none"
	}
	return *minRole
}

// UpsertProjectPolicy creates or replaces a single project-scoped policy
// row using the legacy required_role grammar ('none' → no approval; else
// the strict-ladder role). Thin shim around UpsertProjectPolicySplit kept
// for callers (and tests) that haven't cut over yet.
func (s *ApprovalService) UpsertProjectPolicy(ctx context.Context, callerID, projectID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
	if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
		return nil, err
	}
	requiresApproval, minRole := splitFromLegacy(requiredRole)
	return s.UpsertProjectPolicySplit(ctx, callerID, projectID, entityType, lifecycle, requiresApproval, minRole)
}

// UpsertProjectPolicySplit creates or replaces a single project-scoped
// policy row using the split-grammar (requires_approval, min_role) shape
// (t-paliad-160). Caller must be global_admin (gate enforced at the
// handler layer). Audit row written via writePolicyAudit using the
// legacy required_role string for compatibility with the existing
// policy_audit_log shape.
func (s *ApprovalService) UpsertProjectPolicySplit(
	ctx context.Context, callerID, projectID uuid.UUID,
	entityType, lifecycle string, requiresApproval bool, minRole *string,
) (*models.ApprovalPolicy, error) {
	if err := validatePolicySplit(entityType, lifecycle, requiresApproval, minRole); err != nil {
		return nil, err
	}

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

	// Snapshot pre-existing (requires_approval, min_role) for the audit
	// row. The audit log still uses the legacy string format
	// (partner|of_counsel|...|none) so we project through legacyFromSplit.
	var preReq sql.NullBool
	var preMin sql.NullString
	if err := tx.QueryRowxContext(ctx,
		`SELECT requires_approval, min_role FROM paliad.approval_policies
		  WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
		projectID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil && !errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("upsert project policy: read pre-image: %w", err)
	}
	var oldRole *string
	if preReq.Valid {
		var pm *string
		if preMin.Valid {
			s := preMin.String
			pm = &s
		}
		legacy := legacyFromSplit(preReq.Bool, pm)
		oldRole = &legacy
	}

	requiredRole := legacyFromSplit(requiresApproval, minRole)
	q := `INSERT INTO paliad.approval_policies
	        (project_id, partner_unit_id, entity_type, lifecycle_event,
	         requires_approval, min_role, created_by)
	      VALUES ($1, NULL, $2, $3, $4, $5, $6)
	      ON CONFLICT (project_id, entity_type, lifecycle_event)
	        WHERE project_id IS NOT NULL
	        DO UPDATE SET requires_approval = EXCLUDED.requires_approval,
	                      min_role          = EXCLUDED.min_role,
	                      updated_at = now()
	      RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
	                requires_approval, min_role,
	                created_at, updated_at, created_by`
	var p models.ApprovalPolicy
	if err := tx.GetContext(ctx, &p, q,
		projectID, entityType, lifecycle,
		requiresApproval, minRole, callerID); err != nil {
		return nil, fmt.Errorf("upsert project policy: %w", err)
	}

	// Snapshot project name for the audit row (so cascade-set-null doesn't
	// lose the human label).
	var scopeName string
	if err := tx.GetContext(ctx, &scopeName,
		`SELECT title FROM paliad.projects WHERE id = $1`, projectID); err != nil {
		// Tolerate name lookup failure — still audit with empty scope_name.
		scopeName = ""
	}

	if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
		"project", &projectID, nil, scopeName, entityType, lifecycle,
		oldRole, &requiredRole); err != nil {
		return nil, err
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("upsert project policy: commit: %w", err)
	}
	return &p, nil
}

// DeleteProjectPolicy removes a single project-scoped policy row, reverting
// that cell to inherit from ancestors / unit defaults. Audit row written.
func (s *ApprovalService) DeleteProjectPolicy(ctx context.Context, callerID, projectID uuid.UUID, entityType, lifecycle string) error {
	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return fmt.Errorf("delete project policy: begin tx: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck

	var preReq sql.NullBool
	var preMin sql.NullString
	if err := tx.QueryRowxContext(ctx,
		`SELECT requires_approval, min_role FROM paliad.approval_policies
		  WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
		projectID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil // nothing to delete — no audit needed
		}
		return fmt.Errorf("delete project policy: read pre-image: %w", err)
	}
	var pm *string
	if preMin.Valid {
		s := preMin.String
		pm = &s
	}
	oldRoleStr := legacyFromSplit(preReq.Bool, pm)

	if _, err := tx.ExecContext(ctx,
		`DELETE FROM paliad.approval_policies
		  WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
		projectID, entityType, lifecycle); err != nil {
		return fmt.Errorf("delete project policy: %w", err)
	}

	var scopeName string
	if err := tx.GetContext(ctx, &scopeName,
		`SELECT title FROM paliad.projects WHERE id = $1`, projectID); err != nil {
		scopeName = ""
	}

	if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
		"project", &projectID, nil, scopeName, entityType, lifecycle,
		&oldRoleStr, nil); err != nil {
		return err
	}
	return tx.Commit()
}

// UpsertUnitPolicy creates or replaces a single unit-default policy row
// using the legacy required_role grammar. Thin shim around
// UpsertUnitPolicySplit kept for callers / tests that haven't cut over.
func (s *ApprovalService) UpsertUnitPolicy(ctx context.Context, callerID, unitID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
	if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
		return nil, err
	}
	requiresApproval, minRole := splitFromLegacy(requiredRole)
	return s.UpsertUnitPolicySplit(ctx, callerID, unitID, entityType, lifecycle, requiresApproval, minRole)
}

// UpsertUnitPolicySplit creates or replaces a single unit-default policy
// row using the split-grammar (requires_approval, min_role) shape.
func (s *ApprovalService) UpsertUnitPolicySplit(
	ctx context.Context, callerID, unitID uuid.UUID,
	entityType, lifecycle string, requiresApproval bool, minRole *string,
) (*models.ApprovalPolicy, error) {
	if err := validatePolicySplit(entityType, lifecycle, requiresApproval, minRole); err != nil {
		return nil, err
	}

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

	var preReq sql.NullBool
	var preMin sql.NullString
	if err := tx.QueryRowxContext(ctx,
		`SELECT requires_approval, min_role FROM paliad.approval_policies
		  WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
		unitID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil && !errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("upsert unit policy: read pre-image: %w", err)
	}
	var oldRole *string
	if preReq.Valid {
		var pm *string
		if preMin.Valid {
			s := preMin.String
			pm = &s
		}
		legacy := legacyFromSplit(preReq.Bool, pm)
		oldRole = &legacy
	}

	requiredRole := legacyFromSplit(requiresApproval, minRole)
	q := `INSERT INTO paliad.approval_policies
	        (project_id, partner_unit_id, entity_type, lifecycle_event,
	         requires_approval, min_role, created_by)
	      VALUES (NULL, $1, $2, $3, $4, $5, $6)
	      ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
	        WHERE partner_unit_id IS NOT NULL
	        DO UPDATE SET requires_approval = EXCLUDED.requires_approval,
	                      min_role          = EXCLUDED.min_role,
	                      updated_at = now()
	      RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
	                requires_approval, min_role,
	                created_at, updated_at, created_by`
	var p models.ApprovalPolicy
	if err := tx.GetContext(ctx, &p, q,
		unitID, entityType, lifecycle,
		requiresApproval, minRole, callerID); err != nil {
		return nil, fmt.Errorf("upsert unit policy: %w", err)
	}

	var scopeName string
	if err := tx.GetContext(ctx, &scopeName,
		`SELECT name FROM paliad.partner_units WHERE id = $1`, unitID); err != nil {
		scopeName = ""
	}

	if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
		"unit", nil, &unitID, scopeName, entityType, lifecycle,
		oldRole, &requiredRole); err != nil {
		return nil, err
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("upsert unit policy: commit: %w", err)
	}
	return &p, nil
}

// DeleteUnitPolicy removes a single unit-default policy row.
func (s *ApprovalService) DeleteUnitPolicy(ctx context.Context, callerID, unitID uuid.UUID, entityType, lifecycle string) error {
	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return fmt.Errorf("delete unit policy: begin tx: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck

	var preReq sql.NullBool
	var preMin sql.NullString
	if err := tx.QueryRowxContext(ctx,
		`SELECT requires_approval, min_role FROM paliad.approval_policies
		  WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
		unitID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil
		}
		return fmt.Errorf("delete unit policy: read pre-image: %w", err)
	}
	var pm *string
	if preMin.Valid {
		s := preMin.String
		pm = &s
	}
	oldRoleStr := legacyFromSplit(preReq.Bool, pm)

	if _, err := tx.ExecContext(ctx,
		`DELETE FROM paliad.approval_policies
		  WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
		unitID, entityType, lifecycle); err != nil {
		return fmt.Errorf("delete unit policy: %w", err)
	}

	var scopeName string
	if err := tx.GetContext(ctx, &scopeName,
		`SELECT name FROM paliad.partner_units WHERE id = $1`, unitID); err != nil {
		scopeName = ""
	}

	if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
		"unit", nil, &unitID, scopeName, entityType, lifecycle,
		&oldRoleStr, nil); err != nil {
		return err
	}
	return tx.Commit()
}

// allLifecycleEvents enumerates the 8 (entity_type, lifecycle) cells in
// stable display order: Fristen first (create / update / complete / delete),
// then Termine.
var allLifecycleEvents = []struct {
	EntityType string
	Lifecycle  string
}{
	{EntityTypeDeadline, LifecycleCreate},
	{EntityTypeDeadline, LifecycleUpdate},
	{EntityTypeDeadline, LifecycleComplete},
	{EntityTypeDeadline, LifecycleDelete},
	{EntityTypeAppointment, LifecycleCreate},
	{EntityTypeAppointment, LifecycleUpdate},
	{EntityTypeAppointment, LifecycleComplete},
	{EntityTypeAppointment, LifecycleDelete},
}

// GetEffectivePoliciesMatrix returns one EffectivePolicy per (entity_type,
// lifecycle_event) cell — 8 rows in stable display order. Each row carries
// the resolved (requires_approval, min_role) pair + attribution
// (source ∈ {project, ancestor, unit_default}) + a human-readable
// source name (project title or partner unit name).
//
// requires_approval=false with a non-nil source means the cell has been
// explicitly authored as "no approval needed" at that scope; cells with
// no candidates at all return Source=nil so the admin UI can distinguish
// "inherited off" from "never authored".
func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx context.Context, projectID uuid.UUID) ([]models.EffectivePolicy, error) {
	out := make([]models.EffectivePolicy, 0, len(allLifecycleEvents))
	for _, c := range allLifecycleEvents {
		row, err := s.GetEffectivePolicyOne(ctx, projectID, c.EntityType, c.Lifecycle)
		if err != nil {
			return nil, err
		}
		out = append(out, *row)
	}
	return out, nil
}

// GetEffectivePolicyOne returns the EffectivePolicy for a single cell.
// Used by the form-time hint endpoint on /projects/{id}/deadlines/new etc.
//
// Carries the split-grammar fields: RequiresApproval is the gate, MinRole
// the seniority threshold (NULL when gate off).
func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) (*models.EffectivePolicy, error) {
	var row struct {
		RequiresApproval bool           `db:"requires_approval"`
		MinRole          sql.NullString `db:"min_role"`
		Source           sql.NullString `db:"source"`
		SourceID         *uuid.UUID     `db:"source_id"`
	}
	q := `SELECT requires_approval, min_role, source, source_id
	        FROM paliad.approval_policy_effective($1, $2, $3)`
	if err := s.db.GetContext(ctx, &row, q, projectID, entityType, lifecycle); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return &models.EffectivePolicy{
				EntityType:     entityType,
				LifecycleEvent: lifecycle,
			}, nil
		}
		return nil, fmt.Errorf("effective policy: %w", err)
	}

	res := &models.EffectivePolicy{
		EntityType:       entityType,
		LifecycleEvent:   lifecycle,
		RequiresApproval: row.RequiresApproval,
	}
	if row.MinRole.Valid {
		mr := row.MinRole.String
		res.MinRole = &mr
	}
	if row.Source.Valid {
		src := row.Source.String
		res.Source = &src
	}
	if row.SourceID != nil {
		res.SourceID = row.SourceID
		if name, err := s.lookupSourceName(ctx, *row.SourceID, row.Source.String); err == nil {
			res.SourceName = &name
		}
	}
	return res, nil
}

// lookupSourceName resolves a source_id to a human label depending on the
// source kind. project / ancestor → projects.title; unit_default →
// partner_units.name. Returns ("", err) if the row vanished.
func (s *ApprovalService) lookupSourceName(ctx context.Context, id uuid.UUID, source string) (string, error) {
	var q string
	switch source {
	case "project", "ancestor":
		q = `SELECT title FROM paliad.projects WHERE id = $1`
	case "unit_default":
		q = `SELECT name FROM paliad.partner_units WHERE id = $1`
	default:
		return "", fmt.Errorf("unknown source %q", source)
	}
	var name string
	if err := s.db.GetContext(ctx, &name, q, id); err != nil {
		return "", err
	}
	return name, nil
}

// PoliciesExist returns true iff any approval_policies row exists firm-wide
// (project or unit, any cell). Used by the /inbox empty-state nudge to hide
// the "configure policies" card once any policy is set.
func (s *ApprovalService) PoliciesExist(ctx context.Context) (bool, error) {
	var ok bool
	if err := s.db.GetContext(ctx, &ok,
		`SELECT EXISTS(SELECT 1 FROM paliad.approval_policies LIMIT 1)`); err != nil {
		return false, fmt.Errorf("policies exist check: %w", err)
	}
	return ok, nil
}

// ApplyMatrixToDescendants copies the source project's effective matrix
// down to every project in `targetIDs` as project-specific rows. Idempotent
// fanout — each target's existing project rows for the 8 cells are first
// DELETEd, then the source's effective values INSERTed (excluding cells
// where the source resolves to no policy and the target already has none).
//
// Validates every target is an actual descendant of source via the project
// path. Self-target (source ∈ targetIDs) is silently skipped. Caller must
// be global_admin (handler-layer gate). Audit row per affected target+cell.
//
// Returns the number of policy-cell writes performed (INSERTs + post-clear
// re-applies).
func (s *ApprovalService) ApplyMatrixToDescendants(ctx context.Context, callerID, sourceProjectID uuid.UUID, targetIDs []uuid.UUID) (int, error) {
	if len(targetIDs) == 0 {
		return 0, nil
	}

	// Resolve source's effective matrix (fold inherited values into the
	// target's project-scoped rows for predictable behaviour).
	matrix, err := s.GetEffectivePoliciesMatrix(ctx, sourceProjectID)
	if err != nil {
		return 0, fmt.Errorf("apply matrix: source resolve: %w", err)
	}

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

	// Validate each target_id is a descendant of source. Anything else =
	// caller-bug → ErrInvalidInput.
	if err := s.validateDescendants(ctx, tx, sourceProjectID, targetIDs); err != nil {
		return 0, err
	}

	writes := 0
	for _, target := range targetIDs {
		if target == sourceProjectID {
			continue // skip self
		}
		// Snapshot pre-existing project rows for audit.
		oldRows, err := s.snapshotProjectRows(ctx, tx, target)
		if err != nil {
			return 0, err
		}
		// Wipe the target's 8 cells (project-scoped only — leaves unit-default
		// inheritance intact).
		if _, err := tx.ExecContext(ctx,
			`DELETE FROM paliad.approval_policies
			  WHERE project_id = $1`, target); err != nil {
			return 0, fmt.Errorf("apply matrix: clear target %s: %w", target, err)
		}
		// Apply source's effective values as project-scoped rows. Skip
		// cells where the source has no policy at all (no candidates) —
		// the target is left to inherit from its own ancestors / unit
		// defaults rather than getting a synthetic project row written.
		for _, cell := range matrix {
			if cell.Source == nil {
				continue // no candidates for this cell at the source
			}
			requiresApproval := cell.RequiresApproval
			minRole := cell.MinRole
			if _, err := tx.ExecContext(ctx,
				`INSERT INTO paliad.approval_policies
				   (project_id, partner_unit_id, entity_type, lifecycle_event,
				    requires_approval, min_role, created_by)
				 VALUES ($1, NULL, $2, $3, $4, $5, $6)`,
				target, cell.EntityType, cell.LifecycleEvent,
				requiresApproval, minRole, callerID); err != nil {
				return 0, fmt.Errorf("apply matrix: write target %s cell %s/%s: %w",
					target, cell.EntityType, cell.LifecycleEvent, err)
			}
			writes++
		}

		// Audit one row per target (set-event with a synthesised payload —
		// individual cells are too noisy for the audit timeline).
		var scopeName string
		if err := tx.GetContext(ctx, &scopeName,
			`SELECT title FROM paliad.projects WHERE id = $1`, target); err != nil {
			scopeName = ""
		}
		// Use lifecycle='create' as a stand-in marker for the bulk apply
		// audit row — the meaningful payload is "matrix copied from source".
		// The audit row is informational; the per-cell set/clear are not
		// re-emitted for bulk to avoid log spam.
		_ = oldRows // pre-image not currently surfaced; reserved for future
		if err := s.writePolicyAuditRaw(ctx, tx, callerID, "approval_policy_set",
			"project", &target, nil, scopeName, "deadline", "create",
			nil, strPtr(fmt.Sprintf("bulk-apply from source=%s", sourceProjectID))); err != nil {
			return 0, err
		}
	}

	if err := tx.Commit(); err != nil {
		return 0, fmt.Errorf("apply matrix: commit: %w", err)
	}
	return writes, nil
}

// validateDescendants checks that every target_id is on the source's
// descendant subtree (path LIKE source.path || '.%'). Returns ErrInvalidInput
// listing offending IDs if any are not descendants.
func (s *ApprovalService) validateDescendants(ctx context.Context, tx *sqlx.Tx, sourceID uuid.UUID, targetIDs []uuid.UUID) error {
	if len(targetIDs) == 0 {
		return nil
	}
	q := `WITH src AS (
	        SELECT path FROM paliad.projects WHERE id = $1
	      )
	      SELECT p.id::text
	        FROM paliad.projects p, src
	       WHERE p.id = ANY($2)
	         AND p.path NOT LIKE src.path || '.%'`
	rows, err := tx.QueryxContext(ctx, q, sourceID, pqUUIDArray(targetIDs))
	if err != nil {
		return fmt.Errorf("apply matrix: validate descendants: %w", err)
	}
	defer rows.Close()
	var bad []string
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			return err
		}
		bad = append(bad, id)
	}
	if len(bad) > 0 {
		return fmt.Errorf("%w: not descendants of %s: %s",
			ErrInvalidInput, sourceID, strings.Join(bad, ", "))
	}
	return nil
}

// snapshotProjectRows reads the current project-scoped policy rows for a
// project. Used as audit pre-image during ApplyMatrixToDescendants.
func (s *ApprovalService) snapshotProjectRows(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
	var rows []models.ApprovalPolicy
	if err := tx.SelectContext(ctx, &rows,
		`SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
		        requires_approval, min_role,
		        created_at, updated_at, created_by
		   FROM paliad.approval_policies
		  WHERE project_id = $1`, projectID); err != nil {
		return nil, fmt.Errorf("snapshot project rows: %w", err)
	}
	return rows, nil
}

// writePolicyAudit writes one paliad.policy_audit_log row inside the calling
// tx. tx may be nil in which case we run on s.db directly.
func (s *ApprovalService) writePolicyAudit(
	ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
	eventType, scopeType string, projectID, partnerUnitID *uuid.UUID,
	scopeName, entityType, lifecycle string,
	oldRole, newRole *string,
) error {
	return s.writePolicyAuditRaw(ctx, tx, actorID, eventType, scopeType,
		projectID, partnerUnitID, scopeName, entityType, lifecycle, oldRole, newRole)
}

// writePolicyAuditRaw expects a non-nil tx (the audit row must commit
// atomically with the data mutation).
func (s *ApprovalService) writePolicyAuditRaw(
	ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
	eventType, scopeType string, projectID, partnerUnitID *uuid.UUID,
	scopeName, entityType, lifecycle string,
	oldRole, newRole *string,
) error {
	q := `INSERT INTO paliad.policy_audit_log
	        (actor_id, event_type, scope_type, project_id, partner_unit_id,
	         scope_name, entity_type, lifecycle_event,
	         old_required_role, new_required_role)
	      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
	if _, err := tx.ExecContext(ctx, q,
		actorID, eventType, scopeType, projectID, partnerUnitID,
		scopeName, entityType, lifecycle, oldRole, newRole); err != nil {
		return fmt.Errorf("write policy audit: %w", err)
	}
	return nil
}

// strPtr is a small helper for inline string literals.
func strPtr(s string) *string { return &s }

// pqUUIDArray converts a []uuid.UUID to the pq array format used by the
// sqlx driver. Reuses the github.com/lib/pq Array helper.
func pqUUIDArray(ids []uuid.UUID) any {
	strs := make([]string, len(ids))
	for i, id := range ids {
		strs[i] = id.String()
	}
	return pq.Array(strs)
}
