// Package scim is the published L1 SCIM 2.0 dialect for the
// Salesforce-compatible CRM.
//
// It is pure translation logic: it parses the SCIM 2.0 wire shapes (RFC 7643
// schema, RFC 7644 protocol) that identity providers such as Helix push, and
// maps them onto neutral NexusCRM User field sets. It performs NO record I/O —
// the serving layer (route surface) feeds these results to the records engine
// through its ports, so this package stays a deterministic, unit-testable
// dialect with zero platform coupling. Both the independently deployed sfcrm
// AppService and the temporary in-process compatibility surface consume this
// one owner.
//
// SCIM is L1 dialect content, not L0 mechanism: the /scim/v2 URLs, URNs,
// filter grammar, PATCH operations, and error envelopes are pinned by
// RFC 7643/7644 and cannot be renamed without breaking IdP clients.
package scim

import (
	"encoding/json"
	"fmt"
	"regexp"
	"strings"
)

// SCIM 2.0 schema URNs (RFC 7643 §3, RFC 7644 §3.12/§3.4.2).
const (
	SchemaUser       = "urn:ietf:params:scim:schemas:core:2.0:User"
	SchemaEnterprise = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
	SchemaListResp   = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
	SchemaError      = "urn:ietf:params:scim:api:messages:2.0:Error"
	SchemaPatchOp    = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
)

// NexusCRM User field API names the dialect writes. Password is deliberately
// absent: the dialect never sets it, so provisioned accounts are SSO-only.
const (
	FieldUsername         = "Username"
	FieldEmail            = "Email"
	FieldFirstName        = "FirstName"
	FieldLastName         = "LastName"
	FieldTitle            = "Title"
	FieldPhone            = "Phone"
	FieldMobilePhone      = "MobilePhone"
	FieldDepartment       = "Department"
	FieldManagerID        = "ManagerId"
	FieldIsActive         = "IsActive"
	FieldAlias            = "Alias"
	FieldProfileID        = "ProfileId"
	FieldTimeZoneSidKey   = "TimeZoneSidKey"
	FieldLocaleSidKey     = "LocaleSidKey"
	FieldEmailEncodingKey = "EmailEncodingKey"
	FieldLanguageLocale   = "LanguageLocaleKey"
)

// managedAttributeFields are the fields a full SCIM replacement (PUT) owns.
// On PUT, any of these absent from the payload is cleared so removed
// attributes never linger. FirstName is included; LastName is schema-required
// and is re-derived rather than cleared (see MapUser / ensureLastName).
var managedAttributeFields = []string{
	FieldTitle, FieldPhone, FieldMobilePhone, FieldDepartment,
	FieldManagerID, FieldFirstName,
}

// Error is a SCIM error carrying an HTTP status and the optional scimType
// detail (RFC 7644 §3.12). The serving layer renders it as the SCIM error
// envelope; internal (5xx) detail is redacted by the caller before it crosses
// the boundary.
type Error struct {
	Status   int
	Detail   string
	ScimType string
}

func (e *Error) Error() string { return e.Detail }

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

// ErrorBody renders the RFC 7644 error envelope.
func (e *Error) ErrorBody() map[string]any {
	body := map[string]any{
		"schemas": []string{SchemaError},
		"status":  fmt.Sprintf("%d", e.Status),
		"detail":  e.Detail,
	}
	if e.ScimType != "" {
		body["scimType"] = e.ScimType
	}
	return body
}

// ManagerResolver resolves a manager reference (an email/username the IdP sends
// in enterprise.manager.value) to a NexusCRM User Id. It returns found=false
// when no such user exists so the caller can fail closed rather than silently
// clear an existing manager. An empty reference means "clear the manager".
type ManagerResolver func(reference string) (id string, found bool, err error)

// asString returns a trimmed string for a JSON string and ok=false for anything
// else — including JSON null. Treating null (or a number/object) as ok=false
// means a present-but-null attribute fails closed at the call site (via
// stringAttr) rather than being read as an empty value that a PUT would then
// clear. Absence is distinct: callers detect it via ciGet returning ok=false.
func asString(v any) (string, bool) {
	if s, ok := v.(string); ok {
		return strings.TrimSpace(s), true
	}
	return "", false
}

// str is a convenience for discriminator fields (op, path, type) where a
// non-string value is meaningless and safely collapses to "" (which then flows
// into an "unsupported"/absent branch). It never stringifies structured values.
func str(v any) string {
	s, _ := asString(v)
	return s
}

// ciGet looks up a key case-insensitively. SCIM attribute names are
// case-insensitive (RFC 7643 §2.1), so a payload sending "Active" or "USERNAME"
// must resolve the same as the canonical casing. An exact match wins; otherwise
// the first case-insensitive match is returned. Callers first run
// rejectAmbiguousKeys, so a given key has at most one case-variant here and the
// fallback is deterministic.
func ciGet(m map[string]any, key string) (any, bool) {
	if v, ok := m[key]; ok {
		return v, true
	}
	for k, v := range m {
		if strings.EqualFold(k, key) {
			return v, true
		}
	}
	return nil, false
}

// rejectAmbiguousKeys fails closed on any object that contains two keys
// differing only in case (e.g. "userName" and "USERNAME"). Since attribute
// lookups are case-insensitive, such a payload would resolve nondeterministically
// (map iteration order), risking different identity writes on different passes.
func rejectAmbiguousKeys(v any) error {
	switch t := v.(type) {
	case map[string]any:
		seen := make(map[string]string, len(t))
		for k := range t {
			lk := strings.ToLower(k)
			if prev, ok := seen[lk]; ok {
				return newError(400, "ambiguous duplicate attribute keys: "+prev+" and "+k, "invalidValue")
			}
			seen[lk] = k
		}
		for _, sub := range t {
			if err := rejectAmbiguousKeys(sub); err != nil {
				return err
			}
		}
	case []any:
		for _, e := range t {
			if err := rejectAmbiguousKeys(e); err != nil {
				return err
			}
		}
	}
	return nil
}

// stringAttr reads a string attribute from m (case-insensitive key). present is
// false when the key is absent. When the key is present but not a string (and
// not null) it fails closed with a 400 so malformed values such as userName:{}
// are rejected rather than coerced.
func stringAttr(m map[string]any, key string) (val string, present bool, err error) {
	v, ok := ciGet(m, key)
	if !ok {
		return "", false, nil
	}
	s, valid := asString(v)
	if !valid {
		return "", true, newError(400, "attribute "+key+" must be a string", "invalidValue")
	}
	return s, true, nil
}

// setStr writes a string attribute (case-insensitive key) into fields. A
// non-empty value is always written. A present empty string means "clear" in
// patchClear mode (empty-path PATCH): a clearable field is nulled, while a
// required/identity field (nonBlankableFields) is rejected. Outside patchClear
// mode (a full resource) an empty string is left unset for the caller's
// managed-field clearing / LastName derivation to handle.
func setStr(fields map[string]any, field string, m map[string]any, key string, patchClear bool) error {
	v, present, err := stringAttr(m, key)
	if err != nil {
		return err
	}
	if !present {
		return nil
	}
	if v == "" {
		if !patchClear {
			return nil
		}
		if nonBlankableFields[field] {
			return newError(400, "cannot set an empty "+field+"; use PATCH remove to clear", "invalidValue")
		}
		fields[field] = nil // explicit clear
		return nil
	}
	fields[field] = v
	return nil
}

// User is the parsed, structurally validated inbound SCIM User resource.
type User struct {
	raw    map[string]any
	schema []string
}

// ParseUser decodes and structurally validates a SCIM User payload. It rejects
// anything that is not a SCIM User (missing the User URN, or lacking both
// userName and a primary email) so a malformed or empty body can never be
// treated as a valid full-replacement that wipes a live record. Non-string
// identity values fail closed here.
func ParseUser(body []byte) (*User, error) {
	var raw map[string]any
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, newError(400, "invalid JSON body", "invalidSyntax")
	}
	if err := rejectAmbiguousKeys(raw); err != nil {
		return nil, err
	}
	schemasRaw, _ := ciGet(raw, "schemas")
	schemas, _ := schemasRaw.([]any)
	hasUser := false
	list := make([]string, 0, len(schemas))
	for _, s := range schemas {
		if s2, ok := s.(string); ok {
			list = append(list, s2)
			if s2 == SchemaUser {
				hasUser = true
			}
		}
	}
	if !hasUser {
		return nil, newError(400, "payload is not a SCIM User (missing "+SchemaUser+")", "invalidValue")
	}
	u := &User{raw: raw, schema: list}
	// A present userName must be a non-null, non-empty string (RFC 7643 §4.1.1).
	// Failing closed here stops a blank identity from being silently mirrored
	// from Email in MapUser. An absent userName is still allowed (email-only),
	// where Email supplies the login.
	if v, ok := ciGet(raw, "userName"); ok {
		if v == nil {
			return nil, newError(400, "userName must not be null", "invalidValue")
		}
		s, isStr := asString(v)
		if !isStr {
			return nil, newError(400, "userName must be a string", "invalidValue")
		}
		if s == "" {
			return nil, newError(400, "userName must not be empty", "invalidValue")
		}
	}
	un, _, err := stringAttr(raw, "userName")
	if err != nil {
		return nil, err
	}
	em, err := primaryEmailOf(raw)
	if err != nil {
		return nil, err
	}
	if un == "" && em == "" {
		return nil, newError(400, "SCIM User requires userName (or a primary email)", "invalidValue")
	}
	return u, nil
}

// primaryEmailOf returns the primary email (or the first email if none is
// flagged primary). It fails closed on a malformed emails array/entry/value.
func primaryEmailOf(src map[string]any) (string, error) {
	raw, ok := ciGet(src, "emails")
	if !ok {
		return "", nil
	}
	if raw == nil {
		return "", newError(400, "emails must not be null", "invalidValue")
	}
	emails, ok := raw.([]any)
	if !ok {
		return "", newError(400, "emails must be an array", "invalidValue")
	}
	// Validate EVERY entry before selecting, so a malformed address later in the
	// array (after a valid primary) still fails closed rather than being skipped.
	var first, primaryVal string
	primaryFound := false
	for _, e := range emails {
		m, ok := e.(map[string]any)
		if !ok {
			return "", newError(400, "email entries must be objects", "invalidValue")
		}
		vRaw, hasVal := ciGet(m, "value")
		primary, _, err := parseBoolAttr(m, "primary")
		if err != nil {
			return "", err
		}
		// An email entry must carry a value. A value-less (type-only) entry is
		// malformed and fails closed rather than being skipped — skipping would,
		// on a PUT, let the request succeed while leaving Email stale or unset.
		if !hasVal {
			return "", newError(400, "email entry requires a value", "invalidValue")
		}
		// A present email value must be a non-null, non-empty string. Treating a
		// null/blank value as "absent" would let the userName->Email fallback in
		// MapUser overwrite a real email on a full replacement, so fail closed.
		if vRaw == nil {
			return "", newError(400, "email value must not be null", "invalidValue")
		}
		val, isStr := asString(vRaw)
		if !isStr {
			return "", newError(400, "email value must be a string", "invalidValue")
		}
		if val == "" {
			return "", newError(400, "email value must not be empty", "invalidValue")
		}
		if first == "" {
			first = val
		}
		if primary {
			if primaryFound {
				// Two addresses both flagged primary is ambiguous; array order must
				// not decide the login email, so fail closed.
				return "", newError(400, "multiple primary emails are not allowed", "invalidValue")
			}
			primaryVal = val
			primaryFound = true
		}
	}
	if primaryFound {
		return primaryVal, nil
	}
	return first, nil
}

// parseBoolAttr reads a boolean attribute that accepts a JSON bool or the
// strings "true"/"false". A present value of any other type fails closed so a
// malformed flag (e.g. primary:"yes") can never be silently ignored.
func parseBoolAttr(m map[string]any, key string) (val bool, present bool, err error) {
	v, ok := ciGet(m, key)
	if !ok {
		return false, false, nil
	}
	if v == nil {
		// A present-null boolean is malformed; fail closed rather than treat it
		// as absent (e.g. emails primary:null must not select a fallback email).
		return false, true, newError(400, "attribute "+key+" must be a boolean", "invalidValue")
	}
	switch t := v.(type) {
	case bool:
		return t, true, nil
	case string:
		switch strings.ToLower(strings.TrimSpace(t)) {
		case "true":
			return true, true, nil
		case "false":
			return false, true, nil
		}
	}
	return false, true, newError(400, "attribute "+key+" must be a boolean", "invalidValue")
}

// FieldMap is the neutral set of User field writes the dialect produces.
type FieldMap struct {
	Fields map[string]any
	// Active is the requested IsActive value, or nil when the payload did not
	// carry `active` (so the caller preserves the record's current state).
	Active *bool
}

// mapAttrs maps whichever SCIM User attributes are present in src into fields
// and returns the parsed active value. It performs no create/replace-specific
// logic (identity requirement, managed-field clearing, LastName derivation, or
// email fallback), so it is safely reusable both for a full resource and for an
// empty-path PATCH value that carries only a partial attribute set.
func mapAttrs(src map[string]any, fields map[string]any, resolveManager ManagerResolver, patchClear, ignoreManager bool) (*bool, error) {
	if err := setStr(fields, FieldUsername, src, "userName", patchClear); err != nil {
		return nil, err
	}

	email, err := primaryEmailOf(src)
	if err != nil {
		return nil, err
	}
	if email != "" {
		fields[FieldEmail] = email
	} else if _, present := ciGet(src, "emails"); present {
		// emails was supplied but yielded no usable address (e.g. an empty array).
		// Email is the required login, so fail closed here — before any other
		// attribute is applied — rather than report success while leaving Email
		// stale (PUT / empty-path PATCH replace) or absent (create).
		return nil, newError(400, "emails is present but contains no usable address", "invalidValue")
	}

	if nameRaw, ok := ciGet(src, "name"); ok {
		if nameRaw == nil {
			return nil, newError(400, "name must not be null", "invalidValue")
		}
		name, ok := nameRaw.(map[string]any)
		if !ok {
			return nil, newError(400, "name must be an object", "invalidValue")
		}
		if err := setStr(fields, FieldFirstName, name, "givenName", patchClear); err != nil {
			return nil, err
		}
		if err := setStr(fields, FieldLastName, name, "familyName", patchClear); err != nil {
			return nil, err
		}
	}

	if err := setStr(fields, FieldTitle, src, "title", patchClear); err != nil {
		return nil, err
	}

	if err := mapPhones(src, fields, patchClear); err != nil {
		return nil, err
	}
	if err := mapEnterprise(src, fields, resolveManager, patchClear, ignoreManager); err != nil {
		return nil, err
	}

	return parseActive(src, false)
}

func mapPhones(src map[string]any, fields map[string]any, patchClear bool) error {
	raw, ok := ciGet(src, "phoneNumbers")
	if !ok {
		return nil
	}
	if raw == nil {
		return newError(400, "phoneNumbers must not be null", "invalidValue")
	}
	phones, ok := raw.([]any)
	if !ok {
		return newError(400, "phoneNumbers must be an array", "invalidValue")
	}
	// Select deterministically by type rather than by array position: mobile ->
	// MobilePhone, work -> Phone. A non-work/non-mobile number is only a fallback
	// for Phone when no work number is present, so ordering (e.g. a home number
	// listed before work) can never drop the work number. This matches the
	// work->Phone PATCH mapping.
	var mobile, work, other string
	for _, p := range phones {
		m, ok := p.(map[string]any)
		if !ok {
			return newError(400, "phoneNumbers entries must be objects", "invalidValue")
		}
		// A phone entry must carry a non-null, non-empty string value. Skipping a
		// malformed value would, on a PUT, let the managed-field clearing loop
		// null the phone — turning malformed input into unintended data loss.
		vRaw, hasVal := ciGet(m, "value")
		if !hasVal || vRaw == nil {
			return newError(400, "phoneNumbers entry requires a non-null value", "invalidValue")
		}
		val, isStr := asString(vRaw)
		if !isStr {
			return newError(400, "phoneNumbers value must be a string", "invalidValue")
		}
		if val == "" {
			return newError(400, "phoneNumbers value must not be empty", "invalidValue")
		}
		typ, _, err := stringAttr(m, "type")
		if err != nil {
			return err
		}
		switch {
		case strings.EqualFold(typ, "mobile"):
			if mobile == "" {
				mobile = val
			}
		case strings.EqualFold(typ, "work"):
			if work == "" {
				work = val
			}
		default:
			if other == "" {
				other = val
			}
		}
	}
	phone := work
	if phone == "" {
		phone = other
	}
	// In patchClear mode (empty-path PATCH replace) the supplied phoneNumbers are
	// authoritative: a type with no entry is cleared, so a replace with `[]` (or
	// only a mobile) removes the stale Phone/MobilePhone. Outside patchClear a
	// missing type is simply left unset.
	if mobile != "" {
		fields[FieldMobilePhone] = mobile
	} else if patchClear {
		fields[FieldMobilePhone] = nil
	}
	if phone != "" {
		fields[FieldPhone] = phone
	} else if patchClear {
		fields[FieldPhone] = nil
	}
	return nil
}

func mapEnterprise(src map[string]any, fields map[string]any, resolveManager ManagerResolver, patchClear, ignoreManager bool) error {
	raw, ok := ciGet(src, SchemaEnterprise)
	if !ok {
		return nil
	}
	if raw == nil {
		// A present-null enterprise extension is malformed. Failing closed stops a
		// PUT from clearing ManagerId/Department via the
		// managed-field loop on the strength of a malformed payload.
		return newError(400, "enterprise extension must not be null", "invalidValue")
	}
	ent, ok := raw.(map[string]any)
	if !ok {
		return newError(400, "enterprise extension must be an object", "invalidValue")
	}
	if err := setStr(fields, FieldDepartment, ent, "department", patchClear); err != nil {
		return err
	}
	if ignoreManager {
		return nil
	}
	mgrRaw, ok := ciGet(ent, "manager")
	if !ok {
		// Manager attribute absent: not handled here. On a PUT this leaves
		// ManagerId out of the write, so the managed-field clearing loop nulls it
		// (correct full-replace semantics); on create it is simply unset.
		return nil
	}
	if mgrRaw == nil {
		// A present-but-null manager is malformed; fail closed rather than treat
		// it as absent (which on PUT would silently detach an existing manager).
		return newError(400, "enterprise manager must not be null", "invalidValue")
	}
	// A present manager must resolve to something concrete. A malformed manager
	// (non-object/non-string, an object missing a string value, or an empty/
	// whitespace reference) fails closed so it can never silently clear an
	// existing manager on a PUT replacement.
	if mgr, isObj := mgrRaw.(map[string]any); isObj {
		vRaw, present := ciGet(mgr, "value")
		if !present {
			return newError(400, "enterprise manager present but missing value", "invalidValue")
		}
		if vRaw == nil {
			// A null manager reference is malformed; fail closed rather than
			// treat it as an empty reference that would clear the manager.
			return newError(400, "enterprise manager value must not be null", "invalidValue")
		}
		ref, isStr := asString(vRaw)
		if !isStr {
			return newError(400, "enterprise manager value must be a string", "invalidValue")
		}
		if ref == "" {
			return newError(400, "enterprise manager value must not be empty; omit manager (PUT) or use PATCH remove to clear", "invalidValue")
		}
		return resolveAndSetManager(ref, fields, resolveManager)
	}
	ref, isStr := asString(mgrRaw)
	if !isStr {
		return newError(400, "enterprise manager must be an object or string reference", "invalidValue")
	}
	if ref == "" {
		return newError(400, "enterprise manager reference must not be empty; omit manager (PUT) or use PATCH remove to clear", "invalidValue")
	}
	return resolveAndSetManager(ref, fields, resolveManager)
}

// resolveAndSetManager resolves a NON-empty manager reference to a ManagerId.
// Callers reject empty/whitespace references up front; clearing a manager is
// only ever done via PUT omission (managed-field loop) or PATCH remove, never a
// blank reference, so a malformed value can never silently detach a manager.
func resolveAndSetManager(ref string, fields map[string]any, resolveManager ManagerResolver) error {
	if ref == "" {
		return newError(400, "manager reference must not be empty", "invalidValue")
	}
	if resolveManager == nil {
		return newError(400, "manager reference present but no resolver", "invalidValue")
	}
	id, found, err := resolveManager(ref)
	if err != nil {
		return err
	}
	if !found {
		return newError(400, "manager not found in NexusCRM: "+ref, "invalidValue")
	}
	fields[FieldManagerID] = id
	return nil
}

// MapUser maps a parsed SCIM User to NexusCRM User field writes. When replace
// is true (PUT), managed attributes absent from the payload are cleared to nil
// for true replacement semantics. resolveManager may be nil when the payload
// carries no manager reference.
func (u *User) MapUser(replace bool, resolveManager ManagerResolver) (*FieldMap, error) {
	return u.mapUser(replace, resolveManager, false)
}

// MapUserIgnoringManager maps the same User projection while treating the
// optional enterprise manager attribute as outside this consumer's contract.
// Other enterprise validation and department mapping remain active.
func (u *User) MapUserIgnoringManager(replace bool) (*FieldMap, error) {
	return u.mapUser(replace, nil, true)
}

func (u *User) mapUser(replace bool, resolveManager ManagerResolver, ignoreManager bool) (*FieldMap, error) {
	fields := map[string]any{}
	active, err := mapAttrs(u.raw, fields, resolveManager, false, ignoreManager)
	if err != nil {
		return nil, err
	}

	// Identity fallbacks. SCIM userName is the login; email is the contact
	// address. When the IdP OMITS one of them, mirror the other so both required
	// identity fields are populated and the user stays addressable by the
	// userName filter. The fallback fires only on true absence: an explicitly
	// PRESENT emails/userName that yields no value is malformed (already rejected
	// upstream) or must not silently rewrite the counterpart. ParseUser
	// guarantees at least one is present.
	_, userNamePresent := ciGet(u.raw, "userName")
	_, emailsPresent := ciGet(u.raw, "emails")
	if _, ok := fields[FieldUsername]; !ok && !userNamePresent {
		if v, ok := fields[FieldEmail].(string); ok && v != "" {
			fields[FieldUsername] = v
		}
	}
	if _, ok := fields[FieldEmail]; !ok && !emailsPresent {
		if v, ok := fields[FieldUsername].(string); ok && v != "" {
			fields[FieldEmail] = v
		}
	}
	// Note: a present-but-empty emails (no usable address) is rejected inside
	// mapAttrs, so it never reaches here.

	if replace {
		for _, f := range managedAttributeFields {
			if ignoreManager && f == FieldManagerID {
				continue
			}
			if _, ok := fields[f]; !ok {
				fields[f] = nil
			}
		}
	}

	dn, _, err := stringAttr(u.raw, "displayName")
	if err != nil {
		return nil, err
	}
	ensureLastName(fields, dn)

	return &FieldMap{Fields: fields, Active: active}, nil
}

// ensureLastName guarantees the schema-required LastName is populated. It is a
// no-op when a family name was supplied; otherwise it derives a stable fallback
// from displayName, then the local-part of the username/email, so even a
// minimal create (userName only) and a nameless replacement satisfy the field.
func ensureLastName(fields map[string]any, displayName string) {
	if v, ok := fields[FieldLastName].(string); ok && v != "" {
		return
	}
	if dn := strings.TrimSpace(displayName); dn != "" {
		fields[FieldLastName] = dn
		return
	}
	src, _ := fields[FieldUsername].(string)
	if src == "" {
		src, _ = fields[FieldEmail].(string)
	}
	if idx := strings.IndexByte(src, '@'); idx > 0 {
		src = src[:idx]
	}
	if src = strings.TrimSpace(src); src != "" {
		fields[FieldLastName] = src
	}
}

// parseActive reads the SCIM `active` attribute. It accepts a JSON bool or the
// EXACT strings "true"/"false" only — no case-folding or trimming, so a
// malformed value like "FALSE" or " false " can never be coerced into a silent
// deactivation. Any other present value fails closed (400). When the attribute
// is absent, it returns nil (unless required).
func parseActive(m map[string]any, required bool) (*bool, error) {
	v, present := ciGet(m, "active")
	if !present {
		if required {
			return nil, newError(400, "active is required", "invalidValue")
		}
		return nil, nil
	}
	switch t := v.(type) {
	case bool:
		return &t, nil
	case string:
		switch t {
		case "true":
			b := true
			return &b, nil
		case "false":
			b := false
			return &b, nil
		}
	}
	return nil, newError(400, fmt.Sprintf("active must be a boolean (true/false), got: %v", v), "invalidValue")
}

// userFilterRE matches `userName eq "value"`. Per RFC 7644 §3.4.2.2 the
// attribute name and operator are case-insensitive ((?i)), and the value is a
// JSON string literal so it may carry escapes (\" and \\); it is captured whole
// (including quotes) and JSON-unescaped below.
var userFilterRE = regexp.MustCompile(`(?is)^\s*userName\s+eq\s+("(?:[^"\\]|\\.)*")\s*$`)

// userLookupFilterRE matches the exact SCIM lookup filters backed by the CRM
// User identity fields. It intentionally excludes externalId: that value is not
// persisted on User, so accepting it would alias a different identity and risk
// duplicate provisioning.
var userLookupFilterRE = regexp.MustCompile(`(?is)^\s*(userName|emails\.value)\s+eq\s+("(?:[^"\\]|\\.)*")\s*$`)

// ParseUserFilter preserves the historical `userName eq "x"` parser for
// callers that only accept the SCIM userName form. An empty filter returns
// ("", false, nil).
func ParseUserFilter(filter string) (userName string, present bool, err error) {
	field, value, present, err := ParseUserLookupFilter(filter)
	if err != nil || !present {
		return value, present, err
	}
	if field != FieldUsername {
		return "", false, newError(400, "unsupported filter (only `userName eq \"...\"` is supported): "+filter, "invalidFilter")
	}
	return value, true, nil
}

// ParseUserLookupFilter parses an exact SCIM User lookup filter and returns
// the corresponding CRM field plus value. Both userName and emails.value are
// standard SCIM identity attributes; a direct email lookup is needed to
// reconcile legacy Users whose login name differs from their primary email.
// An empty filter returns ("", "", false, nil).
func ParseUserLookupFilter(filter string) (field string, value string, present bool, err error) {
	if strings.TrimSpace(filter) == "" {
		return "", "", false, nil
	}
	m := userLookupFilterRE.FindStringSubmatch(filter)
	if m == nil {
		return "", "", false, newError(400, "unsupported filter (only `userName eq \"...\"` or `emails.value eq \"...\"` is supported): "+filter, "invalidFilter")
	}
	if err := json.Unmarshal([]byte(m[2]), &value); err != nil {
		return "", "", false, newError(400, "invalid filter value", "invalidFilter")
	}
	switch strings.ToLower(m[1]) {
	case "username":
		field = FieldUsername
	case "emails.value":
		field = FieldEmail
	default:
		return "", "", false, newError(400, "unsupported filter: "+filter, "invalidFilter")
	}
	return field, strings.TrimSpace(value), true, nil
}

// patchPathFields maps SCIM PATCH paths to User fields (RFC 7644 §3.5.2).
var patchPathFields = map[string]string{
	"username":                             FieldUsername,
	"title":                                FieldTitle,
	"name.givenname":                       FieldFirstName,
	"name.familyname":                      FieldLastName,
	"department":                           FieldDepartment,
	`phonenumbers[type eq "mobile"].value`: FieldMobilePhone,
	`phonenumbers[type eq "work"].value`:   FieldPhone,
	// The CRM User has a single Email (the login), so only the explicitly-primary
	// address maps to it. Type-selected addresses (work/home/other) and the
	// unfiltered `emails.value` are NOT mapped: none is guaranteed to be the
	// login, and writing one would overwrite the primary email. They fall through
	// to an "unsupported path" 400; use emails[primary eq true].value or a
	// complex `emails` replace instead.
	`emails[primary eq true].value`: FieldEmail,
}

// ignoredPatchPaths are known SCIM attributes outside the CRM identity
// contract. Accept them as no-ops so a generic IdP can send a richer User
// projection without turning an unrelated identity update into a 400. Keep
// them separate from patchPathFields: they must never map to a CRM field or
// participate in a PUT-style clear.
var ignoredPatchPaths = map[string]bool{
	"division":     true,
	"organization": true,
}

// nonBlankableFields cannot be set to an empty string by a PATCH replace/add:
// Username/Email are the identity and LastName is schema-required. Clearing
// them would strand or invalidate the account, so a blank value fails closed.
var nonBlankableFields = map[string]bool{
	FieldUsername: true,
	FieldEmail:    true,
	FieldLastName: true,
}

// multiValuedSingleFields are single CRM fields collapsed from multi-valued SCIM
// attributes (emails, phoneNumbers). A SCIM `add` targeting one of them cannot
// append to a collection, so such ops fail closed instead of overwriting.
var multiValuedSingleFields = map[string]bool{
	FieldEmail:       true,
	FieldPhone:       true,
	FieldMobilePhone: true,
}

func normalizePatchPath(path string) string {
	p := strings.ToLower(strings.TrimSpace(path))
	// A path may be qualified by either the core User URN or the enterprise
	// extension URN (RFC 7644 §3.5.2). Strip whichever prefix is present so both
	// `urn:...:core:2.0:User:title` and `urn:...:enterprise:2.0:User:department`
	// normalize to their bare attribute path.
	for _, urn := range []string{SchemaEnterprise, SchemaUser} {
		prefix := strings.ToLower(urn) + ":"
		if strings.HasPrefix(p, prefix) {
			p = strings.TrimPrefix(p, prefix)
			break
		}
	}
	return p
}

// ApplyPatch translates a SCIM PATCH request into User field writes. It
// supports the standard path-specific replace/add/remove operations plus an
// empty-path object value carrying a partial attribute set (RFC 7644 §3.5.2).
// Unknown paths and unsupported ops fail closed (400).
func ApplyPatch(body []byte, resolveManager ManagerResolver) (*FieldMap, error) {
	return applyPatch(body, resolveManager, false)
}

// ApplyPatchIgnoringManager applies the normal SCIM PATCH grammar while
// accepting manager operations and manager values in empty-path operations as
// no-ops for a consumer that does not own CRM manager data.
func ApplyPatchIgnoringManager(body []byte) (*FieldMap, error) {
	return applyPatch(body, nil, true)
}

func applyPatch(body []byte, resolveManager ManagerResolver, ignoreManager bool) (*FieldMap, error) {
	var req struct {
		Schemas    []string         `json:"schemas"`
		Operations []map[string]any `json:"Operations"`
	}
	if err := json.Unmarshal(body, &req); err != nil {
		return nil, newError(400, "invalid JSON body", "invalidSyntax")
	}
	for _, op := range req.Operations {
		if err := rejectAmbiguousKeys(op); err != nil {
			return nil, err
		}
	}
	// A PATCH request MUST declare the PatchOp schema (RFC 7644 §3.5.2) so a
	// non-SCIM or misrouted body that merely happens to carry an Operations key
	// can never be translated into User writes.
	hasPatchOp := false
	for _, s := range req.Schemas {
		if s == SchemaPatchOp {
			hasPatchOp = true
		}
	}
	if !hasPatchOp {
		return nil, newError(400, "PATCH body must declare the "+SchemaPatchOp+" schema", "invalidValue")
	}
	if len(req.Operations) == 0 {
		return nil, newError(400, "PATCH body must contain at least one operation", "invalidValue")
	}

	fields := map[string]any{}
	var active *bool

	for _, op := range req.Operations {
		opRaw, _ := ciGet(op, "op")
		name := strings.ToLower(str(opRaw))
		if name != "replace" && name != "add" && name != "remove" {
			return nil, newError(400, fmt.Sprintf("unsupported PATCH op: %v", opRaw), "invalidValue")
		}
		// A present path must be a string. Any present non-string path — object,
		// number, or JSON null — must fail closed rather than collapse to "" and
		// be treated as a whole-resource operation. A whole-resource op omits the
		// path key entirely.
		if pv, ok := ciGet(op, "path"); ok {
			if _, isStr := pv.(string); !isStr {
				return nil, newError(400, "PATCH path must be a string", "invalidPath")
			}
		}
		pathRaw, _ := ciGet(op, "path")
		path := str(pathRaw)
		norm := normalizePatchPath(path)
		value, hasKey := ciGet(op, "value")
		// valuePresent distinguishes an explicit (possibly empty-string) value
		// from an absent or null one. add/replace of a scalar/manager without a
		// present value must fail closed rather than silently clear the field.
		valuePresent := hasKey && value != nil
		if ignoredPatchPaths[norm] || (ignoreManager && (norm == "manager" || norm == "manager.value")) {
			continue
		}

		if name == "remove" {
			switch {
			case norm == "manager" || norm == "manager.value":
				fields[FieldManagerID] = nil
			case norm == "phonenumbers":
				// Remove the whole phone collection -> clear both CRM phone fields.
				fields[FieldPhone] = nil
				fields[FieldMobilePhone] = nil
			case norm == "active":
				return nil, newError(400, "cannot remove active; send active=false to deactivate", "invalidValue")
			default:
				field, ok := patchPathFields[norm]
				if !ok {
					return nil, newError(400, "unsupported PATCH remove path: "+path, "invalidPath")
				}
				if nonBlankableFields[field] {
					return nil, newError(400, "cannot remove required/identity field via PATCH: "+path, "invalidValue")
				}
				fields[field] = nil
			}
			continue
		}

		// add / replace
		switch {
		case path == "" && isObject(value):
			// Empty-path object: a partial attribute set, NOT a full resource.
			// Map it directly so valid partial patches (e.g. {"active":false})
			// are honored instead of being rejected for lacking userName/email.
			obj := value.(map[string]any)
			// `add` on a multi-valued attribute means "append to the collection".
			// The CRM stores a single Email/Phone, so an append can't be honored
			// without clobbering — fail closed rather than silently overwrite.
			if name == "add" {
				if _, has := ciGet(obj, "emails"); has {
					return nil, newError(400, "cannot `add` multi-valued emails via empty-path PATCH; use `replace`", "invalidValue")
				}
				if _, has := ciGet(obj, "phoneNumbers"); has {
					return nil, newError(400, "cannot `add` multi-valued phoneNumbers via empty-path PATCH; use `replace`", "invalidValue")
				}
			}
			// Only `replace` carries clear-on-empty / authoritative semantics; an
			// `add` never nulls an omitted or empty attribute.
			a, err := mapAttrs(obj, fields, resolveManager, name == "replace", ignoreManager)
			if err != nil {
				return nil, err
			}
			if a != nil {
				active = a
			}
		case norm == "active":
			a, err := parseActive(map[string]any{"active": value}, true)
			if err != nil {
				return nil, err
			}
			active = a
		case norm == "manager" || norm == "manager.value":
			if !valuePresent {
				return nil, newError(400, "PATCH manager requires a value; use `remove` to clear the manager", "invalidValue")
			}
			ref, err := managerRef(value)
			if err != nil {
				return nil, err
			}
			if ref == "" {
				return nil, newError(400, "PATCH manager value is empty; use `remove` to clear the manager", "invalidValue")
			}
			if err := resolveAndSetManager(ref, fields, resolveManager); err != nil {
				return nil, err
			}
		case norm == "name" && isObject(value):
			// PATCH on the complex `name` attribute merges the supplied
			// sub-attributes; omitted sub-attributes remain unchanged (RFC 7644
			// §3.5.2.3). givenName is optional, so an empty value clears FirstName;
			// familyName maps to the required LastName, so an empty value fails
			// closed (use it non-empty, or leave it omitted to keep the current).
			nm := value.(map[string]any)
			if gn, present, err := stringAttr(nm, "givenName"); err != nil {
				return nil, err
			} else if present {
				if gn == "" {
					fields[FieldFirstName] = nil
				} else {
					fields[FieldFirstName] = gn
				}
			}
			if fn, present, err := stringAttr(nm, "familyName"); err != nil {
				return nil, err
			} else if present {
				if fn == "" {
					return nil, newError(400, "familyName cannot be empty (LastName is required)", "invalidValue")
				}
				fields[FieldLastName] = fn
			}
		case norm == "emails":
			// Complex emails replace: value is an array of email objects, collapsed
			// to the single login Email. `add` can't append to a single field, so
			// it fails closed rather than overwrite.
			if name == "add" {
				return nil, newError(400, "cannot `add` to multi-valued emails; use `replace`", "invalidValue")
			}
			arr, ok := value.([]any)
			if !ok {
				return nil, newError(400, "PATCH emails value must be an array", "invalidValue")
			}
			email, err := primaryEmailOf(map[string]any{"emails": arr})
			if err != nil {
				return nil, err
			}
			if email == "" {
				return nil, newError(400, "PATCH emails must contain a value", "invalidValue")
			}
			fields[FieldEmail] = email
		case norm == "phonenumbers":
			// Complex phoneNumbers replace: the array is authoritative, mapped to
			// Phone/MobilePhone (an empty array clears both). `add` can't append to
			// the single fields, so it fails closed.
			if name == "add" {
				return nil, newError(400, "cannot `add` to multi-valued phoneNumbers; use `replace`", "invalidValue")
			}
			arr, ok := value.([]any)
			if !ok {
				return nil, newError(400, "PATCH phoneNumbers value must be an array", "invalidValue")
			}
			if err := mapPhones(map[string]any{"phoneNumbers": arr}, fields, true); err != nil {
				return nil, err
			}
		default:
			field, ok := patchPathFields[norm]
			if !ok {
				return nil, newError(400, "unsupported PATCH path: "+path, "invalidPath")
			}
			// Email/Phone/MobilePhone are single fields collapsed from multi-valued
			// SCIM attributes. An `add` can't append to them, so it fails closed
			// rather than silently overwrite the existing login/contact value.
			if name == "add" && multiValuedSingleFields[field] {
				return nil, newError(400, "cannot `add` to multi-valued attribute (maps to "+field+"); use `replace`", "invalidValue")
			}
			if !valuePresent {
				return nil, newError(400, "PATCH "+name+" for "+path+" requires a value; use `remove` to clear", "invalidValue")
			}
			s, isStr := asString(value)
			if !isStr {
				return nil, newError(400, "PATCH value for "+path+" must be a string", "invalidValue")
			}
			if s == "" && nonBlankableFields[field] {
				return nil, newError(400, "PATCH cannot set an empty "+field, "invalidValue")
			}
			fields[field] = s
		}
	}
	return &FieldMap{Fields: fields, Active: active}, nil
}

func isObject(v any) bool { _, ok := v.(map[string]any); return ok }

// managerRef extracts a manager reference from a PATCH value (an object with a
// string `value`, or a bare string). Non-string values fail closed.
func managerRef(value any) (string, error) {
	if m, ok := value.(map[string]any); ok {
		vRaw, present := ciGet(m, "value")
		if !present {
			return "", newError(400, "manager value missing", "invalidValue")
		}
		s, isStr := asString(vRaw)
		if !isStr {
			return "", newError(400, "manager value must be a string", "invalidValue")
		}
		return s, nil
	}
	s, isStr := asString(value)
	if !isStr {
		return "", newError(400, "manager reference must be a string or object", "invalidValue")
	}
	return s, nil
}
