//go:build crm

package crm

// The native SCIM surface has exactly one tenant-policy read path: the
// IdentitySyncSetting singleton installed by the L1 CRM pack and authored by
// the PingCAP L2 pack. This package never writes the row and never reads a
// policy value from process environment. Deployment placement remains a
// separate host concern (NEXUSCRM_SCIM_INPROCESS_ENABLED).

import (
	"context"
	"fmt"
	"strconv"
	"strings"
	"sync"
	"time"

	"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 (
	scimConfigObject        = "IdentitySyncSetting"
	scimConfigSingletonName = "default"
	scimConfigCacheTTL      = 15 * time.Second

	scimCfgFieldDefaultProfile   = "DefaultProfileName"
	scimCfgFieldServiceProfile   = "ServiceProfileName"
	scimCfgFieldServiceUsername  = "ServiceUsername"
	scimCfgFieldExemptPrincipals = "ExemptPrincipals"
	scimCfgFieldBreakerFailures  = "BreakerFailureThreshold"
	scimCfgFieldBreakerCooldown  = "BreakerCooldownSeconds"
	scimCfgFieldTokenTTLHours    = "TokenTtlHours"
	scimCfgFieldTimeZone         = "TimeZoneSidKey"
	scimCfgFieldLocale           = "LocaleSidKey"
	scimCfgFieldEmailEncoding    = "EmailEncodingKey"
	scimCfgFieldLanguageLocale   = "LanguageLocaleKey"
)

var scimConfigSelectFields = []string{
	scimFieldName,
	scimCfgFieldDefaultProfile,
	scimCfgFieldServiceProfile,
	scimCfgFieldServiceUsername,
	scimCfgFieldExemptPrincipals,
	scimCfgFieldBreakerFailures,
	scimCfgFieldBreakerCooldown,
	scimCfgFieldTokenTTLHours,
	scimCfgFieldTimeZone,
	scimCfgFieldLocale,
	scimCfgFieldEmailEncoding,
	scimCfgFieldLanguageLocale,
}

// scimConfigDefaults is deliberately not serveable. Tuning fields retain safe
// defaults so status and the breaker remain well-defined while the L2 pack has
// not yet applied a record; identity and policy fields stay blank so requests
// cannot proceed on an inferred tenant configuration.
func scimConfigDefaults() scimIdentityConfig {
	return scimIdentityConfig{
		exempt:                  map[string]bool{},
		tokenTTL:                scimTokenTTL(0),
		breakerFailureThreshold: 5,
		breakerCooldownSeconds:  300,
	}
}

// scimConfigFromRecord decodes the L2-owned singleton. A missing or malformed
// optional tuning field uses its bounded safe default; required service,
// profile, and exemption fields are never defaulted because that could turn an
// incomplete L2 payload into a serving configuration.
func scimConfigFromRecord(rec contract.Record) scimIdentityConfig {
	cfg := scimConfigDefaults()
	if rec == nil {
		return cfg
	}
	cfg.defaultProfileName = strings.TrimSpace(recStr(rec, scimCfgFieldDefaultProfile))
	cfg.serviceProfile = strings.TrimSpace(recStr(rec, scimCfgFieldServiceProfile))
	cfg.serviceUsername = strings.TrimSpace(recStr(rec, scimCfgFieldServiceUsername))
	cfg.timeZoneSidKey = strings.TrimSpace(recStr(rec, scimCfgFieldTimeZone))
	cfg.localeSidKey = strings.TrimSpace(recStr(rec, scimCfgFieldLocale))
	cfg.emailEncodingKey = strings.TrimSpace(recStr(rec, scimCfgFieldEmailEncoding))
	cfg.languageLocaleKey = strings.TrimSpace(recStr(rec, scimCfgFieldLanguageLocale))
	cfg.exempt = scimParseExemptList(recStr(rec, scimCfgFieldExemptPrincipals))
	if n, ok := scimConfigInt(rec, scimCfgFieldBreakerFailures); ok && n > 0 {
		cfg.breakerFailureThreshold = n
	}
	if n, ok := scimConfigInt(rec, scimCfgFieldBreakerCooldown); ok && n > 0 {
		cfg.breakerCooldownSeconds = n
	}
	if n, ok := scimConfigInt(rec, scimCfgFieldTokenTTLHours); ok {
		cfg.tokenTTL = scimTokenTTL(n)
	}
	return cfg
}

func scimParseExemptList(raw string) map[string]bool {
	out := map[string]bool{}
	for _, principal := range strings.FieldsFunc(raw, func(r rune) bool { return r == '\n' || r == '\r' || r == ',' }) {
		if principal = strings.ToLower(strings.TrimSpace(principal)); principal != "" {
			out[principal] = true
		}
	}
	return out
}

func scimConfigInt(rec contract.Record, key string) (int, bool) {
	if rec == nil {
		return 0, false
	}
	switch v := rec[key].(type) {
	case int:
		return v, true
	case int64:
		return int(v), true
	case float64:
		return int(v), true
	case string:
		n, err := strconv.Atoi(strings.TrimSpace(v))
		return n, err == nil
	default:
		return 0, false
	}
}

// scimConfigServeable defines the only tenant-level activation condition. A
// valid L2 policy must name both profiles and the dedicated service principal,
// and must protect at least one account from IdP-driven deactivation.
func scimConfigServeable(cfg scimIdentityConfig) bool {
	return strings.TrimSpace(cfg.defaultProfileName) != "" &&
		strings.TrimSpace(cfg.serviceProfile) != "" &&
		strings.TrimSpace(cfg.serviceUsername) != "" &&
		len(cfg.exempt) > 0
}

// scimConfigCache serializes store reads so a slower earlier snapshot cannot
// overwrite a newer read. It is read-only by design: L2 pack application is the
// sole writer and no runtime/admin route can mutate the source of truth.
type scimConfigCache struct {
	mu       sync.Mutex
	loadMu   sync.Mutex
	cur      scimIdentityConfig
	loaded   bool
	loadedAt time.Time
}

func newScimConfigCache() *scimConfigCache { return &scimConfigCache{} }

func (cc *scimConfigCache) current() scimIdentityConfig {
	cc.mu.Lock()
	defer cc.mu.Unlock()
	if !cc.loaded {
		return scimConfigDefaults()
	}
	return cc.cur
}

func (cc *scimConfigCache) storeForTest(cfg scimIdentityConfig) {
	cc.mu.Lock()
	cc.cur = cfg
	cc.loaded = true
	cc.loadedAt = time.Now()
	cc.mu.Unlock()
}

// refresh reads the singleton under SystemContext. System context is required
// because IdentitySyncSetting is private and must not gain broad object access
// merely so the least-privilege SCIM service principal can consume policy.
// Errors clear the cached config: continued serving on a stale policy would be
// fail-open after a rejected or incomplete L2 rollout.
func (cc *scimConfigCache) refresh(ctx context.Context, query contract.Query, session *models.UserSession, force bool) error {
	cc.mu.Lock()
	fresh := cc.loaded && time.Since(cc.loadedAt) < scimConfigCacheTTL
	cc.mu.Unlock()
	if fresh && !force {
		return nil
	}
	if query == nil {
		cc.clear()
		return fmt.Errorf("SCIM configuration query port unavailable")
	}

	cc.loadMu.Lock()
	defer cc.loadMu.Unlock()
	if !force {
		cc.mu.Lock()
		fresh = cc.loaded && time.Since(cc.loadedAt) < scimConfigCacheTTL
		cc.mu.Unlock()
		if fresh {
			return nil
		}
	}

	rec, err := scimReadConfigRow(ctx, query, session)
	if err != nil {
		cc.clear()
		return err
	}
	cc.mu.Lock()
	cc.cur = scimConfigFromRecord(rec)
	cc.loaded = true
	cc.loadedAt = time.Now()
	cc.mu.Unlock()
	return nil
}

func (cc *scimConfigCache) clear() {
	cc.mu.Lock()
	cc.cur = scimIdentityConfig{}
	cc.loaded = false
	cc.loadedAt = time.Time{}
	cc.mu.Unlock()
}

func scimReadConfigRow(ctx context.Context, query contract.Query, session *models.UserSession) (contract.Record, error) {
	if query == nil {
		return nil, fmt.Errorf("SCIM configuration query port unavailable")
	}
	sysCtx := constants.WithSecurityContext(ctx, constants.SystemContext, "read L2 SCIM identity-sync configuration singleton")
	res, err := query.Query(sysCtx, queryspec.QuerySpec{
		ObjectAPIName: scimConfigObject,
		SelectFields:  scimConfigSelectFields,
		Filter:        queryspec.FieldPredicate(scimFieldName, "=", scimConfigSingletonName),
		Limit:         1,
		SkipTotalSize: true,
	}, session)
	if err != nil {
		return nil, err
	}
	if res == nil || len(res.Records) == 0 {
		return nil, nil
	}
	return res.Records[0], nil
}
