//go:build crm

package crm

// SCIM 2.0 identity-sync receiving surface for the CRM distribution.
//
// This is the in-process serving layer for the native SCIM capability. It
// mounts /scim/v2 into the CRM HTTP host, authenticates each request with a
// static bearer token (an existing API token, NOT a CRM session), and drives
// User provisioning through the neutral records-engine ports (contract.Records)
// so all CRUD/FLS/validation and session-revocation-on-deactivate invariants
// are enforced by the engine — never a raw DB write.
//
// The SCIM wire grammar and User field mapping live in the L1 dialect package
// github.com/nexuscrm/dialect-sf-crm/scim; this file is only host wiring +
// record I/O. An IdP (e.g. Helix) creates a new hire (POST/PUT), updates
// attributes (PATCH), and offboards (DELETE or active=false → deactivate).

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/nexuscrm/audit"
	"github.com/nexuscrm/auth"
	"github.com/nexuscrm/backend/internal/host/adapters/apihost/middleware"
	"github.com/nexuscrm/backend/internal/host/adapters/apihost/rest/requestctx"
	"github.com/nexuscrm/backend/pkg/serverruntime"
	"github.com/nexuscrm/dialect-sf-crm/scim"
	"github.com/nexuscrm/kernel/pkg/apierr"
	kernelerrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/contract"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/nexuscrm/shared/pkg/queryspec"
)

const (
	scimBasePath      = "/scim/v2"
	scimContentType   = "application/scim+json"
	scimUserResource  = "User"
	scimMaxBodyBytes  = 1 << 20 // 1 MiB — SCIM User payloads are small
	scimUserObject    = "User"
	scimProfileObject = "Profile"
	scimFieldID       = "Id"
	scimFieldName     = "Name"
	scimSessionKey    = "scimUserSession"
	scimListDefault   = 100
	scimListMax       = 500
	// scimInProcessEnabledEnv is deployment topology only: exactly one
	// designated process may bind the interim route. Tenant policy is read from
	// the L2 IdentitySyncSetting singleton, never from environment.
	scimInProcessEnabledEnv = "NEXUSCRM_SCIM_INPROCESS_ENABLED"
)

// scimUserSelectFields is the projection read back for rendering SCIM resources.
var scimUserSelectFields = []string{
	scimFieldID, scim.FieldUsername, scim.FieldEmail, scim.FieldFirstName,
	scim.FieldLastName, scim.FieldIsActive, scim.FieldTitle, scim.FieldPhone,
	scim.FieldMobilePhone, scim.FieldDepartment,
}

// scimIdentityConfig carries the L2-owned tenant defaults a create needs that
// SCIM does not supply (profile, locale, timezone).
type scimIdentityConfig struct {
	defaultProfileName string
	timeZoneSidKey     string
	localeSidKey       string
	emailEncodingKey   string
	languageLocaleKey  string
	// exempt is the set of principals (userName/email, lowercased) that must
	// never be deactivated by a sync, protecting break-glass/admin and current
	// operational accounts from an IdP-driven offboarding.
	exempt map[string]bool
	// serviceUsername/serviceProfile identify the dedicated SCIM provisioning
	// principal that owns the minted bearer token. serviceProfile has NO default:
	// the profile is a tenant metadata fact, so it must be configured explicitly
	// (an unset profile fails closed rather than binding to a privileged default).
	serviceUsername string
	serviceProfile  string
	// tokenTTL bounds the minted bearer's lifetime so it is never a non-expiring
	// credential.
	tokenTTL time.Duration
	// breaker tuning for the deactivation circuit breaker.
	breakerFailureThreshold int
	breakerCooldownSeconds  int
}

// applyScimLocaleConfig copies the explicitly-configured locale/timezone
// values into a create record, omitting unset ones so the platform's User
// metadata defaults apply.
func applyScimLocaleConfig(data map[string]any, cfg scimIdentityConfig) {
	for field, v := range map[string]string{
		scim.FieldTimeZoneSidKey:   cfg.timeZoneSidKey,
		scim.FieldLocaleSidKey:     cfg.localeSidKey,
		scim.FieldEmailEncodingKey: cfg.emailEncodingKey,
		scim.FieldLanguageLocale:   cfg.languageLocaleKey,
	} {
		if v != "" {
			data[field] = v
		}
	}
}

// scimTruthy parses a boolean-ish env flag (default false).
func scimTruthy(v string) bool {
	switch strings.ToLower(strings.TrimSpace(v)) {
	case "1", "true", "yes", "on":
		return true
	}
	return false
}

// scimInterimExceptionExpiry is the fixed expiry of the §6.1 interim exception
// recorded under the serviceification contract's §7.7 (2026-12-31). Past this
// date the surface refuses to mount even with the activation flag set, so an
// unrenewed exception disables itself; renewal requires a reviewed docs
// amendment AND advancing this date in the same change.
var scimInterimExceptionExpiry = time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC)

// scimInterimExceptionLapsed reports whether the recorded exception has expired.
func scimInterimExceptionLapsed(now time.Time) bool {
	return !now.Before(scimInterimExceptionExpiry)
}

// scimRejectApiTokenSession blocks callers whose session was itself created by
// a personal access token from reaching the identity-sync admin surface —
// mirroring the self-service api-token surface. A PAT must not be able to mint
// the long-lived SCIM bearer, which would survive revocation of the PAT and
// defeat credential revocation / incident response.
func scimRejectApiTokenSession(c *gin.Context) {
	if u := requestctx.GetUserFromContext(c); u != nil && u.GrantType == auth.ApiTokenGrantType {
		writeScimAdminJSON(c, http.StatusForbidden,
			scimErrorBody(http.StatusForbidden, "personal access tokens cannot manage the SCIM identity surface; use an interactive session", ""))
		c.Abort()
		return
	}
	c.Next()
}

// scimExpiryMiddleware re-checks the §7.7 expiry on EVERY request, so a
// long-lived process self-disables the moment the recorded date passes — the
// registration-time check alone would let a process started before the expiry
// keep serving until restart, which would not satisfy the trusted contract's
// self-disable requirement.
func scimExpiryMiddleware() gin.HandlerFunc {
	return scimExpiryMiddlewareWithWriter(writeScimJSON)
}

func scimAdminExpiryMiddleware() gin.HandlerFunc {
	return scimExpiryMiddlewareWithWriter(writeScimAdminJSON)
}

func scimExpiryMiddlewareWithWriter(write func(*gin.Context, int, any)) gin.HandlerFunc {
	return func(c *gin.Context) {
		if scimInterimExceptionLapsed(time.Now().UTC()) {
			write(c, http.StatusServiceUnavailable,
				scimErrorBody(http.StatusServiceUnavailable, "the SCIM interim exception has expired (serviceification contract §7.7); this surface is disabled pending a reviewed renewal", ""))
			c.Abort()
			return
		}
		c.Next()
	}
}

// scimTokenTTL converts a configured hour count into a positive duration,
// clamped at BOTH ends: a zero/negative value falls back to the one-year
// default so a minted token can never be permanent (the token service treats
// a nil expiry as "no expiry"), and an absurdly large value is capped at ten
// years so the duration multiplication can never overflow negative.
func scimTokenTTL(hours int) time.Duration {
	if hours <= 0 {
		hours = 8760
	}
	if hours > 87600 { // ten years
		hours = 87600
	}
	return time.Duration(hours) * time.Hour
}

// scimScopeGranted reports whether a token's granted scope is EXACTLY the SCIM
// purpose scope — the single field "scim" and nothing else. The check is
// deliberately exact, not "contains": this route group omits the host
// ScopeEnforcer, so a token that carried a broader scope like "api scim" would
// otherwise be accepted here AND retain generic API authorization elsewhere,
// defeating the purpose isolation. The SCIM mint always issues an exactly-scim
// token, so this rejects only tokens that were never meant for this surface.
// An empty (unrestricted) scope does NOT satisfy this.
func scimScopeGranted(scope string) bool {
	fields := strings.Fields(scope)
	return len(fields) == 1 && fields[0] == scimTokenScope
}

// scimPrincipalBinder confirms an authenticated session is the dedicated SCIM
// service principal, bound by stable record id and the configured
// least-privilege profile (resolved once, then cached). It fails closed on a
// missing profile config, missing service user, profile mismatch, or a
// different token owner — so a pre-existing or wrong principal cannot act.
type scimPrincipalBinder struct {
	serviceUsername string
	serviceProfile  string
	records         func() contract.Records

	mu                sync.Mutex
	resolved          bool
	serviceUserID     string
	requiredProfileID string
}

// verify confirms the session is the dedicated SCIM service principal. The
// service-user id and the configured profile's id are cached only while the
// L2 policy names the same pair. A policy refresh resets the cache before the
// request is bound, so rotating either value cannot leave the former service
// principal authorized. The SESSION's current profile is checked on EVERY
// request because the token service rebuilds it from the live User row.
func (b *scimPrincipalBinder) verify(ctx context.Context, sess *models.UserSession, serviceUsername, serviceProfile string) error {
	if sess == nil {
		return fmt.Errorf("no session")
	}
	if strings.TrimSpace(serviceUsername) == "" || strings.TrimSpace(serviceProfile) == "" {
		return fmt.Errorf("SCIM service profile is not configured")
	}
	b.mu.Lock()
	if b.serviceUsername != serviceUsername || b.serviceProfile != serviceProfile {
		b.serviceUsername = serviceUsername
		b.serviceProfile = serviceProfile
		b.resolved = false
		b.serviceUserID = ""
		b.requiredProfileID = ""
	}
	if !b.resolved {
		recs := b.records()
		if recs == nil {
			b.mu.Unlock()
			return fmt.Errorf("records engine unavailable")
		}
		user, err := recs.RetrieveByField(ctx, scimUserObject, scim.FieldUsername, b.serviceUsername, sess)
		if err != nil {
			b.mu.Unlock()
			return err
		}
		prof, err := recs.RetrieveByField(ctx, scimProfileObject, scimFieldName, b.serviceProfile, sess)
		if err != nil {
			b.mu.Unlock()
			return err
		}
		uid, pid := "", ""
		if user != nil {
			uid = recStr(user, scimFieldID)
		}
		if prof != nil {
			pid = recStr(prof, scimFieldID)
		}
		if uid == "" {
			b.mu.Unlock()
			return fmt.Errorf("SCIM service user not found")
		}
		if pid == "" {
			b.mu.Unlock()
			return fmt.Errorf("SCIM service profile not found")
		}
		b.serviceUserID, b.requiredProfileID, b.resolved = uid, pid, true
	}
	uid, pid := b.serviceUserID, b.requiredProfileID
	b.mu.Unlock()

	if sess.ID != uid {
		return fmt.Errorf("token is not the SCIM service principal")
	}
	// Re-checked every request against the session's live profile.
	if strings.TrimSpace(sess.ProfileID) != pid {
		return fmt.Errorf("SCIM service principal is not on the configured profile")
	}
	// Re-checked every request, NOT just at mint: a permission-set assignment
	// or group membership granted to the service user AFTER the token was
	// minted would otherwise silently expand the long-lived bearer's authority
	// with no rotation. Fail closed on any grant or on a read error.
	elevated, err := scimServiceUserHasElevatedGrants(ctx, b.records(), uid, sess)
	if err != nil {
		return err
	}
	if elevated {
		return fmt.Errorf("SCIM service principal has been granted privileges beyond the service profile; refusing")
	}
	return nil
}

// scimServiceUserHasElevatedGrants reports whether userID carries any
// permission-set assignment or group membership — privileges that stack ON TOP
// of the profile. The dedicated SCIM service account (created by the mint
// flow) has neither; a hit means the account has grants beyond its
// least-privilege profile. A read error returns a non-nil error so callers
// fail closed (the grants cannot be ruled out).
func scimServiceUserHasElevatedGrants(ctx context.Context, recs contract.Records, userID string, sess *models.UserSession) (bool, error) {
	if recs == nil {
		return false, fmt.Errorf("records engine unavailable")
	}
	for _, chk := range []struct{ object, field string }{
		{constants.TablePermissionSetAssignment, constants.FieldPermissionSetAssignment_AssigneeID},
		{constants.TableGroupMember, constants.FieldGroupMember_UserOrGroupID},
	} {
		rec, err := recs.RetrieveByField(ctx, chk.object, chk.field, userID, sess)
		if err != nil {
			return false, fmt.Errorf("could not verify the SCIM service account carries no elevated grants: %w", err)
		}
		if rec != nil {
			return true, nil
		}
	}
	return false, nil
}

// isExempt reports whether a record's userName or email is on the never-
// deactivate exemption list.
func (h *scimIdentityHandler) isExempt(rec contract.Record) bool {
	cfg := h.currentCfg()
	if len(cfg.exempt) == 0 || rec == nil {
		return false
	}
	for _, field := range []string{scim.FieldUsername, scim.FieldEmail} {
		if v := strings.ToLower(recStr(rec, field)); v != "" && cfg.exempt[v] {
			return true
		}
	}
	return false
}

// --- durable deactivation volume bound (transactionally visible audit) ------

const (
	// scimAuditScanBudget caps how many head rows one bound check may scan.
	// If the budget is exhausted before the count is established, the
	// deactivation is refused (fail closed) rather than proceeding unverified.
	scimAuditScanBudget = 2000
)

// scimAuditIsActiveTransition reports whether an audit head row records an
// UPDATE that touched IsActive. Head rows carry the changed field names in
// ChangesJSON ({"fields":[...]}); direction (deactivate vs reactivate) is not
// recorded there, so BOTH directions count — conservative, the bound can only
// trip early, never late. An UPDATE head whose scope cannot be read counts
// toward the bound (fail closed). Non-UPDATE heads (CREATE — the dominant
// legitimate SCIM volume — DELETE/UNDELETE/OWNER_CHANGE) are not
// deactivations and do not count.
func scimAuditIsActiveTransition(row contract.AuditLogRow) bool {
	if row.Action == nil {
		return true // unreadable action — count toward the bound
	}
	if *row.Action != string(audit.ActionUpdate) {
		return false
	}
	if row.ChangesJSON == nil {
		return true // UPDATE head without readable scope — count
	}
	var scope struct {
		Fields []string `json:"fields"`
	}
	if err := json.Unmarshal([]byte(*row.ChangesJSON), &scope); err != nil {
		return true
	}
	if len(scope.Fields) == 0 {
		// Parseable but EMPTY scope ({} or {"fields":null}) is just as
		// unreadable as malformed JSON — a healthy UPDATE head always names
		// at least one changed field. Count it (fail closed).
		return true
	}
	for _, f := range scope.Fields {
		if strings.EqualFold(f, scim.FieldIsActive) {
			return true
		}
	}
	return false
}

// durableDeactivationsInWindow counts the SCIM service principal's IsActive
// transitions inside the volume window from the audit HEAD rows of
// _System_AuditLog (design §6.1 layer 3). The head row commits inside the
// deactivation's own business transaction (audit emitter EmitTx), so the count
// is transactionally visible — no asynchronous fan-out dependency, immediately
// consistent across replicas and restarts — and append-only, so prior
// deactivations can never be hidden and the cap can never be reset. It is read
// through the privileged neutral audit-log port (no caller RLS/FLS
// silent-drops), used here solely for the surface's own safety accounting.
// Counting stops early once the bound is reached.
//
// Pagination safety: the audit-log port orders by (ChangedAt, Id) — a
// deterministic TOTAL order — over an append-only table, so offset pagination
// can at worst re-see a row when a concurrent insert shifts rows down; it can
// never miss one. A duplicate inflates the count, which errs toward refusal —
// the fail-closed direction.
func (h *scimIdentityHandler) durableDeactivationsInWindow(ctx context.Context, session *models.UserSession, windowStart time.Time, bound int) (int, error) {
	al := h.auditLog()
	if al == nil {
		return 0, errors.New("audit log port unavailable")
	}
	// Single query, NO offset pagination. Offset paging over this result is
	// unsafe: the audit head-row Id is a UUID (not monotonic), so the
	// (ChangedAt, Id) tiebreaker does not stabilize ordering within an
	// equal-timestamp cluster — a concurrent insert can shift older rows past
	// the next OFFSET and cause them to be SKIPPED, undercounting the bound. A
	// single SELECT executes against ONE read snapshot, so no concurrent insert
	// can perturb it. The page is bounded by the scan budget; if it comes back
	// full we cannot rule out matching rows beyond it, so we fail closed.
	res, err := al.QueryAuditLog(ctx, contract.AuditLogQueryRequest{
		ObjectAPIName: scimUserObject,
		FieldName:     audit.HeadFieldNameSentinel,
		ChangedByID:   session.ID,
		Since:         windowStart,
		Limit:         scimAuditScanBudget,
		// SUCCESS only. Since #4261 the table also holds FAILURE head rows
		// for saves the validation pipeline REFUSED. A refused save
		// deactivated nobody, so counting one against the durable-
		// deactivation bound would let any rejected User update — for any
		// reason, including ones that never touched IsActive — burn the
		// safety budget and eventually wedge the surface. Worse, a
		// validation-rule rejection carries no `fields` key, which
		// scimAuditIsActiveTransition treats as an unreadable scope and
		// fail-closes into counting it.
		Outcome: string(audit.OutcomeSuccess),
	}, session)
	if err != nil {
		return 0, err
	}
	if res == nil {
		return 0, errors.New("audit log query returned no result")
	}
	n := 0
	for i := range res.Rows {
		if scimAuditIsActiveTransition(res.Rows[i]) {
			n++
			if n >= bound {
				return n, nil // already at/over the cap — refuse regardless of the rest
			}
		}
	}
	if len(res.Rows) >= scimAuditScanBudget {
		// The snapshot filled the budget: matching rows may exist beyond it,
		// so a below-cap count here is not authoritative. Fail closed.
		return 0, fmt.Errorf("audit scan budget exhausted before the deactivation count could be established")
	}
	return n, nil
}

// scimBoundJournalDetail extracts the authored refusal reason for the operator
// journal. The HTTP body redacts 5xx details (the bearer caller is the IdP,
// not the operator); the journal is the admin surface, so it carries the
// precise reason.
func scimBoundJournalDetail(err error) string {
	var se *scim.Error
	if errors.As(err, &se) && strings.TrimSpace(se.Detail) != "" {
		return se.Detail
	}
	return "durable deactivation volume bound reached or unverifiable"
}

// checkDurableDeactivationBound enforces the per-window deactivation volume
// cap against the transactionally visible audit head rows BEFORE each
// deactivation write (design §6.1). It fails closed: when the count cannot be
// established — the audit port is unavailable, errors, or the scan budget is
// exhausted — the deactivation is refused rather than proceeding unbounded.
// On success it returns the pre-write count and window start so the caller can
// verify after the write that the audit trail recorded it.
func (h *scimIdentityHandler) checkDurableDeactivationBound(ctx context.Context, session *models.UserSession, now time.Time) (int, time.Time, error) {
	// The never-deactivate exemption list is the absolute guarantee protecting
	// break-glass/admin accounts (design §6.1 layer 1). Deactivations are
	// refused outright while it is unconfigured — enabling SCIM without any
	// protected principals would leave an IdP-driven offboarding with no
	// backstop for the accounts that must never be disabled.
	if len(h.currentCfg().exempt) == 0 {
		return 0, now, scimErr(http.StatusServiceUnavailable, "SCIM L2 configuration has no exempt principals; deactivations are disabled until a protected principal is configured", "")
	}
	bound, window := h.state.volumeBound()
	windowStart := now.Add(-window)
	n, err := h.durableDeactivationsInWindow(ctx, session, windowStart, bound)
	if err != nil {
		return 0, windowStart, scimErr(http.StatusServiceUnavailable, "could not verify the deactivation volume bound; deactivation refused", "")
	}
	if n >= bound {
		return n, windowStart, scimErr(http.StatusServiceUnavailable, "deactivation volume cap reached for the current window; deactivation refused", "")
	}
	return n, windowStart, nil
}

// exemptionVerifiable reports whether the record carries both identity fields
// the exemption list matches on. A record whose Username or Email the caller
// cannot read (e.g. FLS-hidden) can NOT be verified against the exemption
// list, so deactivation guards must fail closed on it rather than treating
// the hidden identity as non-exempt.
func exemptionVerifiable(rec contract.Record) bool {
	return rec != nil &&
		strings.TrimSpace(recStr(rec, scim.FieldUsername)) != "" &&
		strings.TrimSpace(recStr(rec, scim.FieldEmail)) != ""
}

// reverifyExemptionAfterWrite closes the check-to-write race on the exemption
// guarantee: the pre-write check reads a snapshot, so a concurrent identity
// change (e.g. an admin renaming a user to a protected principal's
// username/email) could make the row exempt by the time the deactivation
// lands. Re-read the row as written; if it is exempt NOW, restore IsActive
// immediately (deactivation is reversible — nothing is deleted) and report
// the refusal.
//
// Fail closed everywhere: a post-write read failure, an unverifiable identity,
// or a failed restore returns a non-nil error so the CALLER reports a
// non-success to the IdP (never a silent success over an unverified or
// unrestored state); a failed restore additionally latches deactivations off.
// rolledBack is true when an exempt row was detected and restored.
// scimRestoreActive re-activates a just-deactivated row with bounded retries.
// The records engine's Update is independently transactional, so each attempt
// either fully restores or fully fails; the retries cover transient failures.
func (h *scimIdentityHandler) scimRestoreActive(ctx context.Context, id string, session *models.UserSession) error {
	var updErr error
	for attempt := 0; attempt < 3; attempt++ {
		if updErr = h.records().Update(ctx, scimUserObject, id, contract.Record{scim.FieldIsActive: true}, session); updErr == nil {
			return nil
		}
	}
	return updErr
}

func (h *scimIdentityHandler) reverifyExemptionAfterWrite(ctx context.Context, id string, session *models.UserSession, now time.Time) (rolledBack bool, err error) {
	if cfgErr := h.refreshCfg(ctx, session, true); cfgErr != nil || !scimConfigServeable(h.currentCfg()) {
		msg := "the deactivation completed but the L2 exemption policy could not be re-verified; the account was restored and deactivations latched off pending operator review"
		if restoreErr := h.scimRestoreActive(ctx, id, session); restoreErr != nil {
			msg = "the deactivation completed, the L2 exemption policy could not be re-verified, and the account could NOT be restored; deactivations latched off pending operator review"
		}
		h.state.latch(now, msg)
		h.state.record(now, "error", "", principalOf(session), msg)
		return false, scimErr(http.StatusServiceUnavailable, msg, "")
	}
	post, readErr := h.records().Retrieve(ctx, scimUserObject, id, session)
	if readErr != nil || post == nil || !exemptionVerifiable(post) {
		// The write landed but whether it hit a protected principal is
		// UNKNOWN. Fail closed on both axes: RESTORE the account (undoing the
		// write is always safe here — if the deactivation was legitimate the
		// IdP's retry redoes it under full verification, and if it raced a
		// protected principal the account must not stay inactive) AND latch
		// further deactivations until an operator reviews the journal.
		msg := "the deactivation completed but the exemption re-check could not run; the account was restored and deactivations latched off pending operator review"
		if restoreErr := h.scimRestoreActive(ctx, id, session); restoreErr != nil {
			msg = "the deactivation completed, the exemption re-check could not run, and the account could NOT be restored; deactivations latched off pending operator review"
		}
		h.state.latch(now, msg)
		h.state.record(now, "error", "", principalOf(session), msg)
		return false, scimErr(http.StatusServiceUnavailable, msg, "")
	}
	if !h.isExempt(post) {
		return false, nil
	}
	if updErr := h.scimRestoreActive(ctx, id, session); updErr != nil {
		msg := "an exempt principal was deactivated by a concurrent identity change and could NOT be restored after retries; deactivations latched off pending operator review"
		h.state.latch(now, msg)
		h.state.record(now, "error", recStr(post, scim.FieldEmail), principalOf(session), msg)
		return false, scimErr(http.StatusServiceUnavailable, msg, "")
	}
	// Honest residual: the engine revoked the account's sessions when
	// IsActive went false, and reactivation does not resurrect them — the
	// principal is active again but must sign in anew. Journaled so the
	// operator knows the account was touched.
	h.state.record(now, "skipped", recStr(post, scim.FieldEmail), principalOf(session), "exempt principal (post-write re-check); deactivation rolled back — the account is active again but its sessions were revoked by the transition and require a fresh sign-in")
	return true, nil
}

// verifyDeactivationRecorded runs AFTER a successful deactivation write and
// confirms the audit head-row count advanced past the pre-write count (same
// window bounds, append-only source, transactionally committed with the write
// — so a recorded deactivation is always visible here). If the audit trail is
// observably NOT recording, further deactivations are latched off until an
// operator resets the breaker: an unrecorded deactivation would blind the
// durable volume bound (design §6.1 layer 3).
func (h *scimIdentityHandler) verifyDeactivationRecorded(ctx context.Context, session *models.UserSession, windowStart time.Time, preCount int, now time.Time, email string) {
	after, err := h.durableDeactivationsInWindow(ctx, session, windowStart, preCount+1)
	if err == nil && after > preCount {
		return
	}
	msg := "audit trail did not record the deactivation; deactivations latched off pending operator review"
	if err != nil {
		msg = "could not verify the audit trail recorded the deactivation; deactivations latched off pending operator review"
	}
	h.state.latch(now, msg)
	h.state.record(now, "error", email, principalOf(session), msg)
}

type scimIdentityHandler struct {
	// recordsFn/queryFn/auditLogFn resolve the neutral engine ports at request
	// time (not at registration), so the surface registers cleanly before the
	// service manager is fully wired, always sees the live contract deps, and
	// stays injectable in unit tests.
	recordsFn  func() contract.Records
	queryFn    func() contract.Query
	auditLogFn func() contract.AuditLog
	// deactivateMu serializes the deactivation critical section (guards,
	// reservation, write, post-write verification) so concurrent requests
	// cannot interleave a check-then-write past the durable bound. The
	// binding single-replica activation (design §6.1) makes a process-local
	// mutex the correct scope; SCIM push traffic is a single sequential IdP,
	// so the serialization is uncontended in practice.
	deactivateMu sync.Mutex
	cc           *scimConfigCache
	state        *scimIdentityState
}

func (h *scimIdentityHandler) records() contract.Records { return h.recordsFn() }
func (h *scimIdentityHandler) query() contract.Query {
	if h.queryFn == nil {
		return nil
	}
	return h.queryFn()
}

func (h *scimIdentityHandler) currentCfg() scimIdentityConfig {
	if h.cc == nil {
		return scimConfigDefaults()
	}
	return h.cc.current()
}

func (h *scimIdentityHandler) refreshCfg(ctx context.Context, session *models.UserSession, force bool) error {
	if h.cc == nil {
		return errors.New("SCIM configuration cache unavailable")
	}
	if err := h.cc.refresh(ctx, h.query(), session, force); err != nil {
		return err
	}
	cfg := h.currentCfg()
	h.state.applyTuning(cfg.breakerFailureThreshold, cfg.breakerCooldownSeconds)
	return nil
}

func (h *scimIdentityHandler) auditLog() contract.AuditLog {
	if h.auditLogFn == nil {
		return nil
	}
	return h.auditLogFn()
}

// registerScimIdentitySurface mounts /scim/v2 under a bearer-auth group. The
// group deliberately does NOT use the CRM session/JWT middleware: SCIM presents
// a static bearer, validated here against the API token service, and every
// record write runs as the token owner (RunAs), gated by that principal's
// permissions.
func registerScimIdentitySurface(ctx serverruntime.RuntimeRouteSurfaceContext) error {
	if ctx.Router == nil {
		return fmt.Errorf("scim identity route surface requires router")
	}
	if ctx.ServiceManager == nil {
		return fmt.Errorf("scim identity route surface requires service manager")
	}
	sm := ctx.ServiceManager
	// Transitional in-process serving is opt-in and OFF by default (design doc
	// §6.1). With the flag unset, nothing mounts — the bridge is never a silent
	// second path in a default build; an operator enables it deliberately until
	// the AppService serving compositions land. The recorded exception also
	// enforces its own §7.7 expiry: once lapsed, the surface refuses to mount
	// even with the flag set, until a reviewed renewal advances the date.
	if !scimTruthy(os.Getenv(scimInProcessEnabledEnv)) || scimInterimExceptionLapsed(time.Now().UTC()) {
		return nil
	}
	cc := newScimConfigCache()
	h := &scimIdentityHandler{
		recordsFn:  func() contract.Records { return sm.ContractDeps().Records },
		queryFn:    func() contract.Query { return sm.ContractDeps().Query },
		auditLogFn: func() contract.AuditLog { return sm.ContractDeps().AuditLog },
		cc:         cc,
		state:      newScimIdentityState(time.Now().UTC(), 5, 300),
	}
	binder := &scimPrincipalBinder{
		records: func() contract.Records { return sm.ContractDeps().Records },
	}
	// Bearer auth. The token is admitted through the STRONG authorizing path
	// (AdmitAuthorizingApiToken — one strong read of the credential, current
	// subject, and Permission State floor, the same admission the production
	// runtime routes require; the legacy Validate cannot satisfy it), AND the
	// authenticated principal must be the dedicated SCIM service account — an
	// arbitrary user PAT (e.g. api.read) is refused, so SCIM write routes cannot
	// be reached with a general token that would otherwise bypass scope checks.
	authn := scimBearerMiddleware(func(reqCtx context.Context, token string) (*models.UserSession, error) {
		apiTokens := sm.ApiToken
		if apiTokens == nil {
			return nil, fmt.Errorf("api token service unavailable")
		}
		validation, err := apiTokens.AdmitAuthorizingApiToken(reqCtx, token)
		if err != nil {
			return nil, err
		}
		if validation == nil || validation.Claims == nil {
			return nil, fmt.Errorf("token produced no session")
		}
		au := validation.Claims.User
		if strings.TrimSpace(au.ID) == "" {
			return nil, fmt.Errorf("token session has no user id")
		}
		// The token MUST carry the SCIM purpose scope. This route omits the host
		// ScopeEnforcer, so without this check a differently-scoped PAT for the
		// accepted principal could perform SCIM CRUD; requiring the scope means
		// only a token minted by the SCIM flow is accepted.
		if !scimScopeGranted(au.Scope) {
			return nil, fmt.Errorf("token lacks the required SCIM scope")
		}
		email := au.Email
		sess := &models.UserSession{
			ID:             au.ID,
			Name:           au.Name,
			Email:          &email,
			ProfileID:      au.ProfileId,
			ProfileName:    au.ProfileName,
			RoleID:         au.RoleId,
			RoleName:       au.RoleName,
			ConnectedAppId: au.ConnectedAppId,
			Scope:          au.Scope,
			GrantType:      au.GrantType,
			SessionClass:   au.SessionClass,
		}
		// Refresh the L2 policy before binding. The config reader uses a privileged
		// neutral query, so the dedicated service principal receives no direct
		// access to the private setting object.
		if err := h.refreshCfg(reqCtx, sess, false); err != nil {
			return nil, fmt.Errorf("SCIM configuration unavailable: %w", err)
		}
		cfg := h.currentCfg()
		// Bind to the dedicated SCIM service principal by its STABLE record id (not
		// email), and verify that account carries the configured least-privilege
		// profile. Fails closed when the profile is unset, the service user is
		// missing, its profile differs, or the token owner is a different id.
		if err := binder.verify(reqCtx, sess, cfg.serviceUsername, cfg.serviceProfile); err != nil {
			return nil, err
		}
		return sess, nil
	})

	// The per-request expiry check runs FIRST on both groups: a long-lived
	// process self-disables the whole surface (serving, admin, token mint) the
	// moment the §7.7 exception lapses, not at its next restart.
	// Read-only SCIM requests may use the short-lived cache. Every mutation
	// forces a fresh L2 read and re-binds the service principal before any record
	// write, so a remotely-applied policy withdrawal or rotation fails closed.
	scimServingGate := func(c *gin.Context) {
		sess := h.session(c)
		force := c.Request.Method != http.MethodGet
		if err := h.refreshCfg(c.Request.Context(), sess, force); err != nil {
			writeScimJSON(c, http.StatusServiceUnavailable, scimErrorBody(http.StatusServiceUnavailable, "SCIM configuration is unavailable; request refused", ""))
			c.Abort()
			return
		}
		cfg := h.currentCfg()
		if !scimConfigServeable(cfg) {
			writeScimJSON(c, http.StatusServiceUnavailable, scimErrorBody(http.StatusServiceUnavailable, "SCIM L2 configuration is incomplete; request refused", ""))
			c.Abort()
			return
		}
		if force {
			if err := binder.verify(c.Request.Context(), sess, cfg.serviceUsername, cfg.serviceProfile); err != nil {
				writeScimJSON(c, http.StatusForbidden, scimErrorBody(http.StatusForbidden, "SCIM service principal is no longer authorized for this configuration; request refused", ""))
				c.Abort()
				return
			}
		}
		c.Next()
	}
	g := ctx.Router.Group(scimBasePath, scimExpiryMiddleware(), authn, scimServingGate)
	g.GET("/Users", h.listUsers)
	g.GET("/Users/:id", h.getUser)
	g.POST("/Users", h.createUser)
	g.PUT("/Users/:id", h.replaceUser)
	g.PATCH("/Users/:id", h.patchUser)
	g.DELETE("/Users/:id", h.deactivateUser)
	g.GET("/ServiceProviderConfig", h.serviceProviderConfig)
	g.GET("/ResourceTypes", h.resourceTypes)
	g.GET("/Schemas", h.schemas)

	// Admin surface: CRM session + ManageUsers permission (not the SCIM bearer).
	// AuthenticatedScopeMiddleware (strong requireAuth + ScopeEnforcer) installs
	// the admitted aggregate snapshot — including SystemPermissions — that
	// RequirePermission reads, WITHOUT the data API's separate PermApiEnabled
	// gate: a ManageUsers setup administrator must be able to mint the token
	// and operate the dashboard even without the distinct ApiEnabled grant.
	admin := &scimAdminHandler{
		recordsFn:     func() contract.Records { return sm.ContractDeps().Records },
		currentCfg:    h.currentCfg,
		state:         h.state,
		callerSession: requestctx.GetUserFromContext,
		mintToken: func(reqCtx context.Context, ownerID, callerScope string) (string, error) {
			if sm.ApiToken == nil {
				return "", fmt.Errorf("api token service unavailable")
			}
			return scimMintServiceToken(reqCtx, sm.TxManager, sm.ApiToken, h.currentCfg().tokenTTL, ownerID, callerScope)
		},
	}
	admGroup := ctx.Router.Group("/services/identity-sync", append([]gin.HandlerFunc{scimAdminExpiryMiddleware()}, ctx.AuthenticatedScopeMiddleware...)...)
	// Reject PAT-authenticated callers on the whole admin surface: this group
	// mints the long-lived SCIM bearer, so a personal access token must not be
	// able to bootstrap a credential that survives the PAT's own revocation
	// (the same rule the self-service api-token surface enforces). The setup
	// dashboard is reached via an interactive session, so this never blocks a
	// legitimate operator.
	admGroup.Use(scimRejectApiTokenSession)
	admGroup.Use(middleware.RequirePermission(constants.PermManageUsers))
	// The admin surface is operational only. Read-only requests may use the
	// short-lived cache, but every state-changing operation (in particular token
	// minting, which can create/reactivate its service user and mint a long-lived
	// bearer) forces a fresh L2 read before any handler runs. This makes a
	// withdrawal or principal rotation fail closed rather than serving it from a
	// stale cache. Token issuance additionally refuses an absent or incomplete
	// policy, so configuration cannot be backfilled through this UI route.
	admGroup.Use(func(c *gin.Context) {
		if err := h.refreshCfg(c.Request.Context(), requestctx.GetUserFromContext(c), scimPolicyReadMustBeFresh(c.Request.Method)); err != nil {
			writeScimAdminJSON(c, http.StatusServiceUnavailable, scimErrorBody(http.StatusServiceUnavailable, "SCIM configuration is unavailable", ""))
			c.Abort()
			return
		}
		c.Next()
	})
	scimAdminServeableGate := func(c *gin.Context) {
		if !scimConfigServeable(h.currentCfg()) {
			writeScimAdminJSON(c, http.StatusServiceUnavailable, scimErrorBody(http.StatusServiceUnavailable, "SCIM L2 configuration is incomplete; token mint refused", ""))
			c.Abort()
			return
		}
		c.Next()
	}
	admGroup.POST("/scim-token", scimAdminServeableGate, admin.mintTokenHandler)
	admGroup.GET("/status", admin.status)
	admGroup.GET("/breaker", admin.breaker)
	admGroup.POST("/breaker/reset", admin.resetBreaker)
	admGroup.GET("/journal", admin.journal)
	admGroup.GET("/quarantine", admin.quarantine)
	admGroup.POST("/quarantine/:id/resolve", admin.resolveQuarantine)
	return nil
}

// scimPolicyReadMustBeFresh separates read-only status inspection from any
// operation that may mutate operational or credential state. A non-GET admin
// request must never authorize itself using the short-lived L2 cache.
func scimPolicyReadMustBeFresh(method string) bool {
	return method != http.MethodGet
}

// --- token mint -------------------------------------------------------------

// scimTokenMinter is the narrow slice of the API-token service the SCIM token
// mint needs (satisfied by *auth.ApiTokenService; faked in tests).
type scimTokenMinter interface {
	RevokeAllNamedForOwnerExcept(ctx context.Context, ownerID, name, keepID string) (int64, error)
	Revoke(ctx context.Context, id, ownerID string) (bool, error)
	CreateToken(ctx context.Context, ownerID, callerScope, name, scope string, expiresAt *time.Time) (string, *models.SystemApiToken, error)
}

// scimMintMu serializes re-mints so two concurrent mint requests cannot
// interleave their rotation steps and leave multiple active bearers. The
// binding single-replica activation (design §6.1) makes a process-local mutex
// the correct scope for this exclusion.
var scimMintMu sync.Mutex

// scimTxRunner is the one transactional capability the mint needs
// (satisfied by ports.TransactionManager; faked in tests). The token
// repository joins the injected transaction through its context executor, so
// every statement inside fn commits or rolls back as one unit.
type scimTxRunner interface {
	WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error
}

// scimMintServiceToken rotates and mints the SCIM service bearer INSIDE ONE
// database transaction: the create of the replacement token and the
// single-statement revoke of every prior token commit together or not at all.
// There is no crash window in which the credential STATE is inconsistent —
// either the priors are still the valid credentials (nothing committed) or
// exactly the new token is (everything committed). The one unavoidable
// residual is shared by any token mint anywhere: if the process dies after
// commit but before the response is delivered, the plaintext (which exists
// only in the response) is lost — the state is still consistent (exactly one
// active token) and the operator-visible failure is recovered by pressing
// re-mint again.
func scimMintServiceToken(ctx context.Context, txm scimTxRunner, apiTokens scimTokenMinter, ttl time.Duration, ownerID, callerScope string) (string, error) {
	scimMintMu.Lock()
	defer scimMintMu.Unlock()

	if txm == nil {
		return "", fmt.Errorf("transaction manager unavailable; token rotation requires transactional execution")
	}
	// Pre-validate that this session may mint a scim-scoped token, using the
	// token service's own clamping rule, so a scoped session is refused before
	// anything changes.
	if cs := strings.TrimSpace(callerScope); cs != "" {
		if _, scopeErr := auth.ComputeEffectiveScope(&cs, scimTokenScope); scopeErr != nil {
			return "", scimErr(http.StatusForbidden, "your session's scope does not permit minting the SCIM token", "")
		}
	}
	var plaintext string
	err := txm.WithTransaction(ctx, func(txCtx context.Context) error {
		expiry := time.Now().UTC().Add(ttl)
		pt, created, err := apiTokens.CreateToken(txCtx, ownerID, callerScope, scimTokenName, scimTokenScope, &expiry)
		if err != nil {
			return err
		}
		if created == nil || strings.TrimSpace(created.ID) == "" {
			return fmt.Errorf("token service returned no token id; rotation aborted")
		}
		// Rotation is scoped to the SCIM credential family by token name: the
		// service account's unrelated tokens (if any pre-exist) are never
		// touched by a SCIM re-mint.
		if _, err := apiTokens.RevokeAllNamedForOwnerExcept(txCtx, ownerID, scimTokenName, created.ID); err != nil {
			return fmt.Errorf("could not revoke prior tokens during rotation: %w", err)
		}
		plaintext = pt
		return nil
	})
	if err != nil {
		return "", err
	}
	return plaintext, nil
}

// --- authentication -------------------------------------------------------

var bearerRE = regexp.MustCompile(`(?i)^bearer[ \t]+(.+)$`)

func bearerToken(header string) string {
	m := bearerRE.FindStringSubmatch(strings.TrimSpace(header))
	if m == nil {
		return ""
	}
	return strings.TrimSpace(m[1])
}

func scimBearerMiddleware(validate func(context.Context, string) (*models.UserSession, error)) gin.HandlerFunc {
	return func(c *gin.Context) {
		token := bearerToken(c.GetHeader("Authorization"))
		if token == "" {
			c.Header("WWW-Authenticate", "Bearer")
			writeScimJSON(c, http.StatusUnauthorized, scimErrorBody(http.StatusUnauthorized, "missing bearer token", ""))
			c.Abort()
			return
		}
		session, err := validate(c.Request.Context(), token)
		if err != nil || session == nil {
			c.Header("WWW-Authenticate", "Bearer")
			writeScimJSON(c, http.StatusUnauthorized, scimErrorBody(http.StatusUnauthorized, "invalid bearer token", ""))
			c.Abort()
			return
		}
		c.Set(scimSessionKey, session)
		c.Next()
	}
}

func (h *scimIdentityHandler) session(c *gin.Context) *models.UserSession {
	v, ok := c.Get(scimSessionKey)
	if !ok {
		return nil
	}
	s, _ := v.(*models.UserSession)
	return s
}

// --- handlers -------------------------------------------------------------

func (h *scimIdentityHandler) listUsers(c *gin.Context) {
	reqCtx := c.Request.Context()
	session := h.session(c)

	if filter := strings.TrimSpace(c.Query("filter")); filter != "" {
		field, value, present, err := scim.ParseUserLookupFilter(filter)
		if err != nil {
			writeScimErr(c, err)
			return
		}
		if present {
			if field == scim.FieldEmail {
				h.listUserByPrimaryEmail(c, reqCtx, session, value)
				return
			}
			rec, err := h.records().RetrieveByField(reqCtx, scimUserObject, field, value, session)
			if err != nil {
				writeScimErr(c, err)
				return
			}
			resources := []any{}
			if rec != nil {
				resources = append(resources, h.renderUser(rec))
			}
			writeScimJSON(c, http.StatusOK, listResponse(resources, len(resources), 1, len(resources)))
			return
		}
	}

	startIndex := atoiDefault(c.Query("startIndex"), 1)
	if startIndex < 1 {
		startIndex = 1
	}
	count := atoiDefault(c.Query("count"), scimListDefault)
	if count < 0 {
		count = 0
	}
	if count > scimListMax {
		count = scimListMax
	}
	// SCIM count=0 means "return totalResults only, no resources" (RFC 7644
	// §3.4.2.4). The query repo treats Limit=0 as unlimited (→ 500 rows), so never
	// pass 0: fetch a single row purely to obtain TotalSize and return no members.
	queryLimit := count
	if queryLimit == 0 {
		queryLimit = 1
	}
	spec := queryspec.QuerySpec{
		ObjectAPIName: scimUserObject,
		SelectFields:  scimUserSelectFields,
		Limit:         queryLimit,
		Offset:        startIndex - 1,
		SortField:     scimFieldID,
		SortDirection: "ASC",
	}
	res, err := h.query().Query(reqCtx, spec, session)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	resources := make([]any, 0, len(res.Records))
	if count > 0 {
		for _, rec := range res.Records {
			resources = append(resources, h.renderUser(rec))
		}
	}
	total := res.TotalSize
	if res.TotalSizeUnknown {
		total = startIndex - 1 + len(res.Records)
	}
	writeScimJSON(c, http.StatusOK, listResponse(resources, total, startIndex, len(resources)))
}

// listUserByPrimaryEmail resolves emails.value without assuming the CRM Email
// field is unique. SCIM conflict reconciliation must never update an arbitrary
// record when a legacy CRM contains duplicate primary-email values, so read two
// records and require the total to be known and exactly one before returning it.
func (h *scimIdentityHandler) listUserByPrimaryEmail(c *gin.Context, ctx context.Context, session *models.UserSession, email string) {
	res, err := h.query().Query(ctx, queryspec.QuerySpec{
		ObjectAPIName: scimUserObject,
		SelectFields:  scimUserSelectFields,
		Filter:        queryspec.FieldPredicate(scim.FieldEmail, "=", email),
		Limit:         2,
		SortField:     scimFieldID,
		SortDirection: "ASC",
	}, session)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	if res == nil || res.TotalSizeUnknown {
		writeScimErr(c, scimErr(http.StatusServiceUnavailable, "cannot verify primary email uniqueness", ""))
		return
	}
	if res.TotalSize > 1 {
		writeScimErr(c, scimErr(http.StatusConflict, "primary email matches multiple users", "uniqueness"))
		return
	}
	if res.TotalSize != len(res.Records) {
		writeScimErr(c, scimErr(http.StatusServiceUnavailable, "cannot verify primary email uniqueness", ""))
		return
	}
	resources := []any{}
	if res.TotalSize == 1 && len(res.Records) == 1 {
		resources = append(resources, h.renderUser(res.Records[0]))
	}
	writeScimJSON(c, http.StatusOK, listResponse(resources, len(resources), 1, len(resources)))
}

func (h *scimIdentityHandler) getUser(c *gin.Context) {
	rec, err := h.records().Retrieve(c.Request.Context(), scimUserObject, c.Param("id"), h.session(c))
	if err != nil {
		writeScimErr(c, err)
		return
	}
	if rec == nil {
		writeScimErr(c, scimErr(http.StatusNotFound, "user not found", ""))
		return
	}
	writeScimJSON(c, http.StatusOK, h.renderUser(rec))
}

func (h *scimIdentityHandler) createUser(c *gin.Context) {
	reqCtx := c.Request.Context()
	session := h.session(c)
	body, err := readScimBody(c)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	u, err := scim.ParseUser(body)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	fm, err := u.MapUser(false, h.managerResolver(reqCtx, session))
	if err != nil {
		writeScimErr(c, err)
		return
	}
	username, _ := fm.Fields[scim.FieldUsername].(string)
	if username != "" {
		existing, err := h.records().RetrieveByField(reqCtx, scimUserObject, scim.FieldUsername, username, session)
		if err != nil {
			writeScimErr(c, err)
			return
		}
		if existing != nil {
			writeScimErr(c, scimErr(http.StatusConflict, "user already exists: "+username, "uniqueness"))
			return
		}
	}
	data, err := h.applyCreateDefaults(reqCtx, session, fm)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	rec, err := h.records().Insert(reqCtx, scimUserObject, contract.Record(data), session)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	h.state.record(time.Now().UTC(), "provisioned", recStr(rec, scim.FieldEmail), principalOf(session), "created via SCIM POST")
	writeScimJSON(c, http.StatusCreated, h.renderUser(rec))
}

func (h *scimIdentityHandler) replaceUser(c *gin.Context) {
	reqCtx := c.Request.Context()
	session := h.session(c)
	body, err := readScimBody(c)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	u, err := scim.ParseUser(body)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	fm, err := u.MapUser(true, h.managerResolver(reqCtx, session))
	if err != nil {
		writeScimErr(c, err)
		return
	}
	h.applyUpdate(c, c.Param("id"), fm)
}

func (h *scimIdentityHandler) patchUser(c *gin.Context) {
	reqCtx := c.Request.Context()
	session := h.session(c)
	body, err := readScimBody(c)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	fm, err := scim.ApplyPatch(body, h.managerResolver(reqCtx, session))
	if err != nil {
		writeScimErr(c, err)
		return
	}
	h.applyUpdate(c, c.Param("id"), fm)
}

// applyUpdate writes a mapped field set to an existing User and returns the
// refreshed SCIM resource. The identity fields Username/Email are never part of
// the managed replacement set, so a full-replace never rewrites the login.
func (h *scimIdentityHandler) applyUpdate(c *gin.Context, id string, fm *scim.FieldMap) {
	reqCtx := c.Request.Context()
	session := h.session(c)

	now := time.Now().UTC()
	updates := map[string]any{}
	for k, v := range fm.Fields {
		updates[k] = v
	}
	// Enforce the invariant this function documents: an update NEVER rewrites
	// the login identity. The record is addressed by the URL :id; the dialect
	// mapper puts userName/primary-email into Username/Email for PUT, so strip
	// them from the managed set — a full replace can change a user's title,
	// department, phone, active-state, but never their Username or Email.
	delete(updates, scim.FieldUsername)
	delete(updates, scim.FieldEmail)
	deactivating := fm.Active != nil && !*fm.Active
	if fm.Active != nil {
		updates[scim.FieldIsActive] = *fm.Active
	}

	// A deactivation must honor the exemption list and the deactivation breaker,
	// so an IdP-driven offboard can never disable a protected account nor run
	// away. Retrieve first to know the identity. The whole deactivation
	// critical section — state read, guards, reservation, write, post-write
	// verification — is serialized (deactivateMu, held to the end of the
	// request via defer) so concurrent requests cannot interleave a
	// check-then-write past the durable bound or the idempotency guard.
	var current contract.Record
	var preCount int
	var windowStart time.Time
	if deactivating {
		h.deactivateMu.Lock()
		defer h.deactivateMu.Unlock()
		var err error
		current, err = h.records().Retrieve(reqCtx, scimUserObject, id, session)
		if err != nil {
			writeScimErr(c, err)
			return
		}
		if current == nil {
			writeScimErr(c, scimErr(http.StatusNotFound, "user not found", ""))
			return
		}
		if session != nil && recStr(current, scimFieldID) == session.ID {
			// The SCIM service principal is implicitly protected: letting the
			// bearer deactivate its own account would brick the integration
			// (token validation rejects an inactive owner).
			h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "the SCIM service principal cannot deactivate itself")
			writeScimErr(c, scimErr(http.StatusForbidden, "the SCIM service principal cannot deactivate itself", "mutability"))
			return
		}
		if !exemptionVerifiable(current) {
			// Fail closed: an identity the caller cannot read (e.g. FLS-hidden
			// Username/Email) cannot be checked against the exemption list —
			// it must never pass as "not exempt".
			h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "identity unreadable; exemption unverifiable; deactivation refused (fail closed)")
			writeScimErr(c, scimErr(http.StatusServiceUnavailable, "cannot verify the user against the exemption list; deactivation refused", ""))
			return
		}
		if h.isExempt(current) {
			h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "exempt principal; deactivation refused")
			writeScimErr(c, scimErr(http.StatusForbidden, "principal is exempt from deactivation", "mutability"))
			return
		}
		isActive, readable := recBoolPresent(current, scim.FieldIsActive)
		if !readable {
			// Fail closed: the guard cannot run on a state it cannot read (e.g.
			// IsActive hidden from the service principal by FLS while the
			// engine's write path could still change it — skipping the guard
			// here would bypass the durable bound entirely).
			h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "activation state unreadable; deactivation refused (fail closed)")
			writeScimErr(c, scimErr(http.StatusServiceUnavailable, "cannot verify the user's current activation state; deactivation refused", ""))
			return
		}
		if !isActive {
			// Already inactive: the deactivation is an idempotent no-op. This is
			// load-bearing for the safety accounting, not just convenience — an
			// unchanged IsActive=false update is a no-op in the records engine
			// and emits NO audit head row, so running the deactivation machinery
			// would fail the post-write verification and latch deactivations off
			// on a routine IdP retry. Any other attribute changes still apply.
			//
			// Drop IsActive from the write set entirely: if a concurrent actor
			// reactivated the user between our read and the write, a leftover
			// IsActive=false would re-deactivate while bypassing every guard
			// (volume bound, reservation, exemption re-check, audit
			// verification). The idempotent success is about the state we read;
			// the write must not carry the stale transition.
			delete(updates, scim.FieldIsActive)
			h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "already inactive; idempotent deactivation")
			deactivating = false
		} else {
			preCount, windowStart, err = h.checkDurableDeactivationBound(reqCtx, session, now)
			if err != nil {
				h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), scimBoundJournalDetail(err))
				writeScimErr(c, err)
				return
			}
			if !h.state.reserveDeactivation(now) {
				h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "deactivation breaker open")
				writeScimErr(c, scimErr(http.StatusServiceUnavailable, "deactivation temporarily disabled (circuit breaker open)", ""))
				return
			}
		}
	}

	if len(updates) > 0 {
		if err := h.records().Update(reqCtx, scimUserObject, id, contract.Record(updates), session); err != nil {
			if deactivating {
				h.state.onDeactivateFailure(now, "deactivation write failed")
				h.state.record(now, "error", recStr(current, scim.FieldEmail), principalOf(session), "deactivation failed")
			}
			writeScimErr(c, err)
			return
		}
		if deactivating {
			h.state.onDeactivateSuccess(now)
			rolledBack, verifyErr := h.reverifyExemptionAfterWrite(reqCtx, id, session, now)
			if rolledBack {
				writeScimErr(c, scimErr(http.StatusForbidden, "principal is exempt from deactivation", "mutability"))
				return
			}
			if verifyErr != nil {
				// Never report success over an unverified or unrestored state.
				writeScimErr(c, verifyErr)
				return
			}
			h.verifyDeactivationRecorded(reqCtx, session, windowStart, preCount, now, recStr(current, scim.FieldEmail))
		}
	}
	rec, err := h.records().Retrieve(reqCtx, scimUserObject, id, session)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	if rec == nil {
		writeScimErr(c, scimErr(http.StatusNotFound, "user not found", ""))
		return
	}
	evType := "updated"
	if deactivating {
		evType = "deactivated"
	}
	h.state.record(now, evType, recStr(rec, scim.FieldEmail), principalOf(session), "updated via SCIM "+c.Request.Method)
	writeScimJSON(c, http.StatusOK, h.renderUser(rec))
}

// deactivateUser maps SCIM DELETE to a deactivation (IsActive=false). The CRM
// hard-blocks User deletion; setting IsActive=false revokes the user's sessions
// transactionally in the records engine.
func (h *scimIdentityHandler) deactivateUser(c *gin.Context) {
	reqCtx := c.Request.Context()
	session := h.session(c)
	id := c.Param("id")

	now := time.Now().UTC()
	// Serialize the whole deactivation critical section (see applyUpdate):
	// concurrent requests must not interleave a check-then-write past the
	// durable bound or the idempotency guard.
	h.deactivateMu.Lock()
	defer h.deactivateMu.Unlock()
	current, err := h.records().Retrieve(reqCtx, scimUserObject, id, session)
	if err != nil {
		writeScimErr(c, err)
		return
	}
	if current == nil {
		writeScimErr(c, scimErr(http.StatusNotFound, "user not found", ""))
		return
	}
	if session != nil && recStr(current, scimFieldID) == session.ID {
		// The SCIM service principal is implicitly protected (see applyUpdate).
		h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "the SCIM service principal cannot deactivate itself")
		writeScimErr(c, scimErr(http.StatusForbidden, "the SCIM service principal cannot deactivate itself", "mutability"))
		return
	}
	if !exemptionVerifiable(current) {
		// Fail closed: an identity the caller cannot read cannot be checked
		// against the exemption list (see applyUpdate).
		h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "identity unreadable; exemption unverifiable; deactivation refused (fail closed)")
		writeScimErr(c, scimErr(http.StatusServiceUnavailable, "cannot verify the user against the exemption list; deactivation refused", ""))
		return
	}
	if h.isExempt(current) {
		h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "exempt principal; deactivation refused")
		writeScimErr(c, scimErr(http.StatusForbidden, "principal is exempt from deactivation", "mutability"))
		return
	}
	isActive, readable := recBoolPresent(current, scim.FieldIsActive)
	if !readable {
		// Fail closed: the guard cannot run on a state it cannot read (see the
		// PATCH/PUT path — an FLS-hidden IsActive must never skip the guard).
		h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "activation state unreadable; deactivation refused (fail closed)")
		writeScimErr(c, scimErr(http.StatusServiceUnavailable, "cannot verify the user's current activation state; deactivation refused", ""))
		return
	}
	if !isActive {
		// SCIM DELETE is idempotent: already inactive is success with no write.
		// Load-bearing for the safety accounting — an unchanged IsActive=false
		// update is a no-op in the records engine and emits NO audit head row,
		// so re-running the machinery would fail the post-write verification
		// and latch deactivations off on a routine IdP retry.
		h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "already inactive; idempotent DELETE")
		c.Status(http.StatusNoContent)
		return
	}
	preCount, windowStart, err := h.checkDurableDeactivationBound(reqCtx, session, now)
	if err != nil {
		h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), scimBoundJournalDetail(err))
		writeScimErr(c, err)
		return
	}
	if !h.state.reserveDeactivation(now) {
		h.state.record(now, "skipped", recStr(current, scim.FieldEmail), principalOf(session), "deactivation breaker open")
		writeScimErr(c, scimErr(http.StatusServiceUnavailable, "deactivation temporarily disabled (circuit breaker open)", ""))
		return
	}
	if err := h.records().Update(reqCtx, scimUserObject, id, contract.Record{scim.FieldIsActive: false}, session); err != nil {
		h.state.onDeactivateFailure(now, "deactivation write failed")
		h.state.record(now, "error", recStr(current, scim.FieldEmail), principalOf(session), "deactivation failed")
		writeScimErr(c, err)
		return
	}
	h.state.onDeactivateSuccess(now)
	rolledBack, verifyErr := h.reverifyExemptionAfterWrite(reqCtx, id, session, now)
	if rolledBack {
		writeScimErr(c, scimErr(http.StatusForbidden, "principal is exempt from deactivation", "mutability"))
		return
	}
	if verifyErr != nil {
		// Never report success over an unverified or unrestored state.
		writeScimErr(c, verifyErr)
		return
	}
	h.verifyDeactivationRecorded(reqCtx, session, windowStart, preCount, now, recStr(current, scim.FieldEmail))
	h.state.record(now, "deactivated", recStr(current, scim.FieldEmail), principalOf(session), "deactivated via SCIM DELETE")
	c.Status(http.StatusNoContent)
}

// principalOf returns a stable label for the acting SCIM principal for journal
// attribution (email if known, else the user id).
func principalOf(session *models.UserSession) string {
	if session == nil {
		return ""
	}
	if session.Email != nil && strings.TrimSpace(*session.Email) != "" {
		return *session.Email
	}
	return session.ID
}

// --- create defaults + manager resolution ---------------------------------

var aliasStripRE = regexp.MustCompile(`[^A-Za-z0-9]`)

func deriveAlias(username string) string {
	local := username
	if i := strings.IndexByte(local, '@'); i > 0 {
		local = local[:i]
	}
	local = aliasStripRE.ReplaceAllString(local, "")
	if len(local) > 8 {
		local = local[:8]
	}
	if local == "" {
		return "scimuser"
	}
	return local
}

// applyCreateDefaults layers tenant defaults (profile, alias, locale, active)
// under the mapped SCIM fields, which win. It never sets a Password, so
// provisioned accounts are SSO-only (the engine stamps an unguessable
// placeholder).
func (h *scimIdentityHandler) applyCreateDefaults(ctx context.Context, session *models.UserSession, fm *scim.FieldMap) (map[string]any, error) {
	username, _ := fm.Fields[scim.FieldUsername].(string)
	if username == "" {
		if email, ok := fm.Fields[scim.FieldEmail].(string); ok {
			username = email
		}
	}
	if username == "" {
		return nil, scimErr(http.StatusBadRequest, "userName (or a primary email) is required", "invalidValue")
	}
	profileID, err := h.defaultProfileID(ctx, session)
	if err != nil {
		return nil, err
	}
	data := map[string]any{
		scim.FieldAlias:     deriveAlias(username),
		scim.FieldProfileID: profileID,
		scim.FieldIsActive:  true,
	}
	// Locale/timezone are set only when explicitly configured; otherwise the
	// fields are omitted so the platform's User metadata defaults apply (the
	// single source of truth — never a hardcoded parallel default here).
	applyScimLocaleConfig(data, h.currentCfg())
	for k, v := range fm.Fields {
		if v == nil {
			continue
		}
		data[k] = v
	}
	if fm.Active != nil {
		data[scim.FieldIsActive] = *fm.Active
	}
	return data, nil
}

func (h *scimIdentityHandler) defaultProfileID(ctx context.Context, session *models.UserSession) (string, error) {
	cfg := h.currentCfg()
	if strings.TrimSpace(cfg.defaultProfileName) == "" {
		return "", scimErr(http.StatusInternalServerError, "SCIM default profile is not configured in the L2 pack", "")
	}
	rec, err := h.records().RetrieveByField(ctx, scimProfileObject, scimFieldName, cfg.defaultProfileName, session)
	if err != nil {
		return "", err
	}
	if rec == nil {
		return "", scimErr(http.StatusInternalServerError, "default SCIM profile not found: "+cfg.defaultProfileName, "")
	}
	id := recStr(rec, scimFieldID)
	if id == "" {
		return "", scimErr(http.StatusInternalServerError, "default SCIM profile has no id", "")
	}
	return id, nil
}

// managerResolver resolves a SCIM manager reference (an email or username the
// IdP sends) to a NexusCRM User Id. It fails closed: a read error propagates,
// and a reference that resolves to no visible user returns found=false so the
// dialect rejects the write rather than silently clearing the manager.
func (h *scimIdentityHandler) managerResolver(ctx context.Context, session *models.UserSession) scim.ManagerResolver {
	return func(reference string) (string, bool, error) {
		reference = strings.TrimSpace(reference)
		if reference == "" {
			return "", false, nil
		}
		for _, field := range []string{scim.FieldUsername, scim.FieldEmail} {
			rec, err := h.records().RetrieveByField(ctx, scimUserObject, field, reference, session)
			if err != nil {
				return "", false, err
			}
			if rec != nil {
				if id := recStr(rec, scimFieldID); id != "" {
					return id, true, nil
				}
			}
		}
		return "", false, nil
	}
}

// --- rendering + response helpers -----------------------------------------

func (h *scimIdentityHandler) renderUser(rec contract.Record) map[string]any {
	id := recStr(rec, scimFieldID)
	schemas := []string{scim.SchemaUser}

	res := map[string]any{
		"id":       id,
		"userName": recStr(rec, scim.FieldUsername),
		"active":   recBool(rec, scim.FieldIsActive),
		"meta": map[string]any{
			"resourceType": scimUserResource,
			"location":     scimBasePath + "/Users/" + id,
		},
	}

	name := map[string]any{}
	if v := recStr(rec, scim.FieldFirstName); v != "" {
		name["givenName"] = v
	}
	if v := recStr(rec, scim.FieldLastName); v != "" {
		name["familyName"] = v
	}
	if len(name) > 0 {
		res["name"] = name
	}
	if v := recStr(rec, scim.FieldEmail); v != "" {
		res["emails"] = []any{map[string]any{"value": v, "primary": true}}
	}
	if v := recStr(rec, scim.FieldTitle); v != "" {
		res["title"] = v
	}
	phones := []any{}
	if v := recStr(rec, scim.FieldMobilePhone); v != "" {
		phones = append(phones, map[string]any{"type": "mobile", "value": v})
	}
	if v := recStr(rec, scim.FieldPhone); v != "" {
		phones = append(phones, map[string]any{"type": "work", "value": v})
	}
	if len(phones) > 0 {
		res["phoneNumbers"] = phones
	}
	ent := map[string]any{}
	if v := recStr(rec, scim.FieldDepartment); v != "" {
		ent["department"] = v
	}
	if len(ent) > 0 {
		res[scim.SchemaEnterprise] = ent
		schemas = append(schemas, scim.SchemaEnterprise)
	}
	res["schemas"] = schemas
	return res
}

func listResponse(resources []any, total, startIndex, perPage int) map[string]any {
	return map[string]any{
		"schemas":      []string{scim.SchemaListResp},
		"totalResults": total,
		"startIndex":   startIndex,
		"itemsPerPage": perPage,
		"Resources":    resources,
	}
}

func scimErr(status int, detail, scimType string) *scim.Error {
	return &scim.Error{Status: status, Detail: detail, ScimType: scimType}
}

func scimErrorBody(status int, detail, scimType string) map[string]any {
	body := map[string]any{
		"schemas": []string{scim.SchemaError},
		"status":  strconv.Itoa(status),
		"detail":  detail,
	}
	if scimType != "" {
		body["scimType"] = scimType
	}
	return body
}

// writeScimErr renders any error as a SCIM error envelope. Dialect (*scim.Error)
// and records-engine (*apierr.Error) errors keep their status; 4xx details are
// safe to surface, 5xx and unknown errors are redacted so no internal detail
// crosses the boundary.
func writeScimErr(c *gin.Context, err error) {
	writeScimErrWith(c, err, writeScimJSON)
}

func writeScimAdminErr(c *gin.Context, err error) {
	writeScimErrWith(c, err, writeScimAdminJSON)
}

func writeScimErrWith(c *gin.Context, err error, write func(*gin.Context, int, any)) {
	var se *scim.Error
	if errors.As(err, &se) {
		status := se.Status
		detail := se.Detail
		if status >= 500 {
			detail = "internal error"
		}
		write(c, status, scimErrorBody(status, detail, se.ScimType))
		return
	}
	// Records-engine errors: apierr.Error and the kernel appError family
	// (NotFound / Permission / FLS / Validation / Conflict / Forbidden / ...) all
	// expose an HTTP status. Map to it so a PUT/PATCH permission/FLS/not-found
	// failure returns the correct 403/400/404, not a blanket 500.
	status := http.StatusInternalServerError
	detail := err.Error()
	var ae *apierr.Error
	if errors.As(err, &ae) {
		status = ae.HTTPStatus()
	} else if s := kernelerrors.GetHTTPStatus(err); s != 0 {
		status = s
	}
	if status <= 0 {
		status = http.StatusInternalServerError
	}
	scimType := ""
	switch status {
	case http.StatusConflict:
		scimType = "uniqueness"
	case http.StatusBadRequest:
		scimType = "invalidValue"
	}
	if status >= 500 {
		detail = "internal error" // never leak internal detail across the boundary
	}
	write(c, status, scimErrorBody(status, detail, scimType))
}

func writeScimJSON(c *gin.Context, status int, payload any) {
	// Marshal then write via Data so the SCIM content type is set BEFORE the
	// header is flushed. (c.JSON writes application/json and flushes, after which
	// a header Set is a no-op on a real ResponseWriter.)
	b, err := json.Marshal(payload)
	if err != nil {
		c.Data(http.StatusInternalServerError, scimContentType,
			[]byte(`{"schemas":["`+scim.SchemaError+`"],"status":"500","detail":"internal error"}`))
		return
	}
	c.Data(status, scimContentType, b)
}

func readScimBody(c *gin.Context) ([]byte, error) {
	c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scimMaxBodyBytes)
	body, err := io.ReadAll(c.Request.Body)
	if err != nil {
		return nil, scimErr(http.StatusRequestEntityTooLarge, "request body too large or unreadable", "")
	}
	if len(body) == 0 {
		return nil, scimErr(http.StatusBadRequest, "a request body is required", "invalidSyntax")
	}
	return body, nil
}

func recStr(rec contract.Record, key string) string {
	if rec == nil {
		return ""
	}
	s, _ := rec[key].(string)
	return strings.TrimSpace(s)
}

func recBool(rec contract.Record, key string) bool {
	v, _ := recBoolPresent(rec, key)
	return v
}

// recBoolPresent returns a field's boolean value and whether it was present
// and parseable. Guard decisions (the deactivation path) MUST use this form:
// a missing field — e.g. hidden by FLS from the service principal's read
// while the engine's ManageUsers-backed write path could still change it —
// must fail closed, never default to a guard-skipping value.
func recBoolPresent(rec contract.Record, key string) (value bool, ok bool) {
	if rec == nil {
		return false, false
	}
	raw, present := rec[key]
	if !present || raw == nil {
		return false, false
	}
	switch v := raw.(type) {
	case bool:
		return v, true
	case string:
		s := strings.TrimSpace(v)
		if strings.EqualFold(s, "true") {
			return true, true
		}
		if strings.EqualFold(s, "false") {
			return false, true
		}
		return false, false
	case int:
		return v != 0, true
	case int64:
		return v != 0, true
	case float64:
		return v != 0, true
	}
	return false, false
}

func atoiDefault(s string, def int) int {
	s = strings.TrimSpace(s)
	if s == "" {
		return def
	}
	n, err := strconv.Atoi(s)
	if err != nil {
		return def
	}
	return n
}

// --- SCIM discovery documents (RFC 7644 §4) --------------------------------

func (h *scimIdentityHandler) serviceProviderConfig(c *gin.Context) {
	writeScimJSON(c, http.StatusOK, map[string]any{
		"schemas":               []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"},
		"documentationUri":      "",
		"patch":                 map[string]any{"supported": true},
		"bulk":                  map[string]any{"supported": false, "maxOperations": 0, "maxPayloadSize": 0},
		"filter":                map[string]any{"supported": true, "maxResults": scimListMax},
		"changePassword":        map[string]any{"supported": false},
		"sort":                  map[string]any{"supported": false},
		"etag":                  map[string]any{"supported": false},
		"authenticationSchemes": []any{map[string]any{"type": "oauthbearertoken", "name": "OAuth Bearer Token", "description": "Static bearer token issued by the CRM"}},
	})
}

func (h *scimIdentityHandler) resourceTypes(c *gin.Context) {
	writeScimJSON(c, http.StatusOK, listResponse([]any{
		map[string]any{
			"schemas":  []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"},
			"id":       "User",
			"name":     "User",
			"endpoint": "/Users",
			"schema":   scim.SchemaUser,
			"schemaExtensions": []any{map[string]any{
				"schema":   scim.SchemaEnterprise,
				"required": false,
			}},
			"meta": map[string]any{"resourceType": "ResourceType", "location": scimBasePath + "/ResourceTypes/User"},
		},
	}, 1, 1, 1))
}

func (h *scimIdentityHandler) schemas(c *gin.Context) {
	writeScimJSON(c, http.StatusOK, listResponse([]any{
		map[string]any{"id": scim.SchemaUser, "name": "User"},
		map[string]any{"id": scim.SchemaEnterprise, "name": "EnterpriseUser"},
	}, 2, 1, 2))
}
