//go:build crm

package crm

// Admin surface for the native SCIM identity capability: the token-mint button
// endpoint and the operational dashboard endpoints (status / breaker / journal /
// quarantine) consumed by the Identity Sync setup UI. These routes require a CRM
// session with ManageUsers (setup) permission — distinct from the /scim/v2
// receiving routes, which use a static bearer.
//
// Operational state (deactivation breaker, event journal, counters) is
// process-local: it protects a running node from a runaway offboarding and gives
// operators live visibility. The durable safety guarantees are enforced in the
// serving layer independently of this state: the never-deactivate exemption
// list (configuration, checked on every request) and the per-window deactivation
// volume bound (checked against record state via the neutral query port before
// each write — design §6.1 "durable deactivation bound").

import (
	"context"
	"encoding/json"
	"net/http"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/nexuscrm/dialect-sf-crm/scim"
	"github.com/nexuscrm/kernel/pkg/utils"
	"github.com/nexuscrm/shared/pkg/contract"
	"github.com/nexuscrm/shared/pkg/models"
)

const (
	scimTokenName = "SCIM Provisioning Token"
	// scimTokenScope purpose-binds the minted bearer. It is not one of the data
	// API / Salesforce scopes, so the host ScopeEnforcer rejects this token on
	// generic routes — a leaked SCIM token can only reach the /scim/v2 surface
	// (which validates it and binds it to the service principal), not unrelated
	// APIs.
	scimTokenScope     = "scim"
	scimJournalCap     = 500
	scimJournalMaxPage = 200
)

// writeScimAdminJSON serves the interactive CRM setup API. It deliberately
// differs from writeScimJSON: only the RFC 7644 receiver at /scim/v2 exposes
// application/scim+json. The browser's ordinary API client accepts JSON but
// rejects the SCIM media type, so sharing the receiver writer makes the
// Identity Sync dashboard fail even when its handlers return valid JSON.
func writeScimAdminJSON(c *gin.Context, status int, payload any) {
	b, err := json.Marshal(payload)
	if err != nil {
		c.JSON(http.StatusInternalServerError, map[string]any{"error": "internal error"})
		return
	}
	c.Data(status, "application/json; charset=utf-8", b)
}

// --- operational state ----------------------------------------------------

type scimJournalEvent struct {
	id, eventType, email, principal, detail string
	at                                      time.Time
}

type scimIdentityState struct {
	mu               sync.Mutex
	startedAt        time.Time
	failureThreshold int
	cooldown         time.Duration
	// maxDeactivations bounds SUCCESSFUL deactivations within a rolling window
	// (the cooldown period), so a valid token can't quietly offboard everyone —
	// the failure-count breaker alone would reset on each success.
	maxDeactivations int

	breakerOpen      bool
	halfOpenInFlight bool
	failureCount     int
	openedAt         time.Time
	lastFailureAt    time.Time
	lastError        string
	// latched is a hard halt on deactivations that never half-opens: set when
	// the audit trail observably fails to record a deactivation (which would
	// blind the durable volume bound, design §6.1 layer 3). Only an explicit
	// operator breaker reset clears it.
	latched bool

	// recentDeactivations holds the timestamps of admitted deactivation
	// reservations within the current rolling window. A true rolling window
	// (prune-then-count on each reservation), NOT a tumbling one — a tumbling
	// counter that reset at the window boundary would admit a cap-sized burst
	// just before AND just after the boundary, up to 2× the cap. Bounded in
	// size by maxDeactivations (at the cap, further reservations are refused).
	recentDeactivations []time.Time

	processed, provisioned, updated, deactivated, skipped, errors int
	lastRunAt                                                     time.Time

	journal []scimJournalEvent // most-recent-last ring, capped
}

func newScimIdentityState(now time.Time, failureThreshold, cooldownSeconds int) *scimIdentityState {
	// Clamp BOTH bounds: a huge configured cooldown would overflow the
	// time.Duration multiplication to a negative value, which would roll the
	// volume window on every request and neuter both deactivation caps.
	if failureThreshold < 1 {
		failureThreshold = 5
	}
	if failureThreshold > 1000 {
		failureThreshold = 1000
	}
	if cooldownSeconds < 1 {
		cooldownSeconds = 300
	}
	if cooldownSeconds > 86400 { // one day
		cooldownSeconds = 86400
	}
	return &scimIdentityState{
		startedAt:        now,
		failureThreshold: failureThreshold,
		cooldown:         time.Duration(cooldownSeconds) * time.Second,
		maxDeactivations: scimMaxDeactivationsPerWindow(failureThreshold),
	}
}

// rollingDeactivationCount prunes reservations older than the window and
// returns how many remain (caller holds s.mu).
func (s *scimIdentityState) rollingDeactivationCount(now time.Time) int {
	cutoff := now.Add(-s.cooldown)
	kept := s.recentDeactivations[:0]
	for _, t := range s.recentDeactivations {
		if t.After(cutoff) {
			kept = append(kept, t)
		}
	}
	s.recentDeactivations = kept
	return len(kept)
}

// scimMaxDeactivationsPerWindow bounds successful deactivations per cooldown
// window, derived from the failure threshold (generous relative to it, floor 20).
func scimMaxDeactivationsPerWindow(failureThreshold int) int {
	n := failureThreshold * 10
	if n < 20 {
		n = 20
	}
	return n
}

// applyTuning applies the current L2 policy to the process-local breaker. It
// preserves observations and reservations, only changing their bounds. A
// newly-tightened cap therefore refuses subsequent requests immediately rather
// than clearing prior deactivations or resetting the breaker.
func (s *scimIdentityState) applyTuning(failureThreshold, cooldownSeconds int) {
	normalized := newScimIdentityState(time.Now().UTC(), failureThreshold, cooldownSeconds)
	s.mu.Lock()
	s.failureThreshold = normalized.failureThreshold
	s.cooldown = normalized.cooldown
	s.maxDeactivations = normalized.maxDeactivations
	s.rollingDeactivationCount(time.Now().UTC())
	s.mu.Unlock()
}

// volumeBound snapshots the two correlated settings under one lock so a
// concurrent L2 refresh cannot mix an old cap with a new time window.
func (s *scimIdentityState) volumeBound() (int, time.Duration) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.maxDeactivations, s.cooldown
}

func (s *scimIdentityState) volumeCap() int {
	cap, _ := s.volumeBound()
	return cap
}

func (s *scimIdentityState) volumeWindow() time.Duration {
	_, window := s.volumeBound()
	return window
}

// reserveDeactivation atomically decides whether a deactivation may proceed and,
// when the breaker is in its half-open (post-cooldown) window, RESERVES the
// single trial so a concurrent burst can't slip many deactivations through at
// once. A closed breaker permits normal operation; an open breaker blocks until
// cooldown elapses, after which exactly one trial is admitted until it resolves
// (onDeactivateSuccess closes the breaker; onDeactivateFailure re-opens it). The
// caller MUST pair a true result with exactly one onDeactivateSuccess/Failure.
func (s *scimIdentityState) reserveDeactivation(now time.Time) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	// A latch never half-opens: deactivations stay off until an operator
	// resets the breaker (the audit trail was observed not recording).
	if s.latched {
		return false
	}
	if s.breakerOpen {
		if now.Sub(s.openedAt) < s.cooldown {
			return false // still cooling down
		}
		if s.halfOpenInFlight {
			return false // another half-open trial is already in flight
		}
	}
	// Enforce the volume cap over a ROLLING window BEFORE the write (counting
	// the reservation, not the post-success outcome), so a concurrent burst
	// cannot exceed the cap between the check and the write, and a burst
	// straddling a window boundary cannot exceed it either. The half-open
	// trial goes through the SAME accounting — every admitted deactivation,
	// probe or not, is counted, so the per-replica bound stays exact across
	// breaker transitions. This in-memory reservation is exact within a
	// replica; the replica-independent bound is enforced separately against
	// durable record state before each write (checkDurableDeactivationBound,
	// design §6.1).
	if s.rollingDeactivationCount(now) >= s.maxDeactivations {
		s.breakerOpen = true
		s.openedAt = now
		s.lastError = "deactivation volume threshold exceeded"
		return false
	}
	s.recentDeactivations = append(s.recentDeactivations, now)
	if s.breakerOpen {
		s.halfOpenInFlight = true
	}
	return true
}

func (s *scimIdentityState) onDeactivateSuccess(now time.Time) {
	s.mu.Lock()
	defer s.mu.Unlock()
	// A half-open trial that succeeds closes the breaker; volume is already
	// accounted at reservation time.
	s.halfOpenInFlight = false
	s.failureCount = 0
	s.lastError = ""
	s.breakerOpen = false
}

func (s *scimIdentityState) onDeactivateFailure(now time.Time, msg string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.halfOpenInFlight = false
	s.failureCount++
	s.lastFailureAt = now
	s.lastError = msg
	if s.failureCount >= s.failureThreshold {
		s.breakerOpen = true
		s.openedAt = now
	}
}

func (s *scimIdentityState) resetBreaker() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.breakerOpen = false
	s.halfOpenInFlight = false
	s.failureCount = 0
	s.lastError = ""
	s.openedAt = time.Time{}
	s.lastFailureAt = time.Time{}
	// The explicit operator reset is the ONLY way a latch clears.
	s.latched = false
}

// latch hard-halts deactivations until an operator resets the breaker. Used
// when the audit trail observably failed to record a deactivation, which would
// blind the durable volume bound (design §6.1 layer 3).
func (s *scimIdentityState) latch(now time.Time, msg string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.latched = true
	s.breakerOpen = true
	s.openedAt = now
	s.lastFailureAt = now
	s.lastError = msg
}

func (s *scimIdentityState) record(now time.Time, eventType, email, principal, detail string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.processed++
	s.lastRunAt = now
	switch eventType {
	case "provisioned":
		s.provisioned++
	case "updated":
		s.updated++
	case "deactivated":
		s.deactivated++
	case "skipped":
		s.skipped++
	case "error":
		s.errors++
	}
	ev := scimJournalEvent{id: utils.GenerateID(), eventType: eventType, email: email, principal: principal, detail: detail, at: now}
	s.journal = append(s.journal, ev)
	if len(s.journal) > scimJournalCap {
		s.journal = s.journal[len(s.journal)-scimJournalCap:]
	}
}

func rfc3339OrEmpty(t time.Time) any {
	if t.IsZero() {
		return nil
	}
	return t.UTC().Format(time.RFC3339)
}

func (s *scimIdentityState) breakerStatus() (string, int) {
	if !s.breakerOpen {
		return "closed", s.failureCount
	}
	// A latch never half-opens — it reads "open" until the operator resets.
	if !s.latched && time.Now().UTC().Sub(s.openedAt) >= s.cooldown {
		return "half_open", s.failureCount
	}
	return "open", s.failureCount
}

// --- admin handler --------------------------------------------------------

type scimAdminHandler struct {
	recordsFn     func() contract.Records
	currentCfg    func() scimIdentityConfig
	state         *scimIdentityState
	mintToken     func(ctx context.Context, ownerID, callerScope string) (string, error)
	callerSession func(c *gin.Context) *models.UserSession
}

func (a *scimAdminHandler) records() contract.Records { return a.recordsFn() }

func (a *scimAdminHandler) cfg() scimIdentityConfig {
	if a.currentCfg == nil {
		return scimConfigDefaults()
	}
	return a.currentCfg()
}

// refuseElevatedGrants fails closed if the user carries any permission-set
// assignment or group membership — privileges that stack on top of the
// least-privilege profile. An account minted by this flow has neither, so a
// hit means a pre-existing (possibly privileged) account is squatting the
// service username; refuse to bind the SCIM bearer to it. A read error also
// fails closed. Shares the per-request binder's check so mint-time and
// request-time enforcement stay identical.
func (a *scimAdminHandler) refuseElevatedGrants(ctx context.Context, userID string, caller *models.UserSession) error {
	elevated, err := scimServiceUserHasElevatedGrants(ctx, a.records(), userID, caller)
	if err != nil {
		return scimErr(http.StatusInternalServerError, "could not verify the SCIM service account carries no elevated grants; refusing to mint", "")
	}
	if elevated {
		return scimErr(http.StatusInternalServerError, "existing SCIM service user carries permission-set or group grants beyond the service profile; refusing to mint", "")
	}
	return nil
}

func (a *scimAdminHandler) status(c *gin.Context) {
	a.state.mu.Lock()
	status, failureCount := a.state.breakerStatus()
	breaker := map[string]any{
		"status":           status,
		"failureCount":     failureCount,
		"failureThreshold": a.state.failureThreshold,
		"cooldownSeconds":  int(a.state.cooldown / time.Second),
		"openedAt":         rfc3339OrEmpty(a.state.openedAt),
		"lastFailureAt":    rfc3339OrEmpty(a.state.lastFailureAt),
		"lastError":        emptyToNil(a.state.lastError),
	}
	lastRun := map[string]any{
		"runId":       nil,
		"startedAt":   rfc3339OrEmpty(a.state.startedAt),
		"finishedAt":  rfc3339OrEmpty(a.state.lastRunAt),
		"status":      "streaming",
		"processed":   a.state.processed,
		"provisioned": a.state.provisioned,
		"updated":     a.state.updated,
		"deactivated": a.state.deactivated,
		"skipped":     a.state.skipped,
		"quarantined": 0,
		"errors":      a.state.errors,
	}
	a.state.mu.Unlock()

	writeScimAdminJSON(c, http.StatusOK, map[string]any{
		"breaker": breaker,
		"lastRun": lastRun,
		"config":  a.configPayload(),
	})
}

func (a *scimAdminHandler) configPayload() map[string]any {
	cfg := a.cfg()
	exempt := make([]string, 0, len(cfg.exempt))
	for p := range cfg.exempt {
		exempt = append(exempt, p)
	}
	sort.Strings(exempt)
	return map[string]any{
		"provisioningSource":      "scim",
		"defaultProfile":          cfg.defaultProfileName,
		"deptProfileMap":          []any{},
		"exemptPrincipals":        exempt,
		"breakerFailureThreshold": cfg.breakerFailureThreshold,
		"breakerCooldownSeconds":  cfg.breakerCooldownSeconds,
	}
}

func (a *scimAdminHandler) breaker(c *gin.Context) {
	a.state.mu.Lock()
	status, failureCount := a.state.breakerStatus()
	payload := map[string]any{
		"status":           status,
		"failureCount":     failureCount,
		"failureThreshold": a.state.failureThreshold,
		"cooldownSeconds":  int(a.state.cooldown / time.Second),
		"openedAt":         rfc3339OrEmpty(a.state.openedAt),
		"lastFailureAt":    rfc3339OrEmpty(a.state.lastFailureAt),
		"lastError":        emptyToNil(a.state.lastError),
	}
	a.state.mu.Unlock()
	writeScimAdminJSON(c, http.StatusOK, payload)
}

func (a *scimAdminHandler) resetBreaker(c *gin.Context) {
	a.state.resetBreaker()
	a.breaker(c)
}

func (a *scimAdminHandler) journal(c *gin.Context) {
	limit := atoiDefault(c.Query("limit"), 50)
	if limit < 1 {
		limit = 1
	}
	if limit > scimJournalMaxPage {
		limit = scimJournalMaxPage
	}
	offset := atoiDefault(c.Query("offset"), 0)
	if offset < 0 {
		offset = 0
	}

	a.state.mu.Lock()
	total := len(a.state.journal)
	// Newest first.
	ordered := make([]scimJournalEvent, total)
	for i, ev := range a.state.journal {
		ordered[total-1-i] = ev
	}
	a.state.mu.Unlock()

	events := make([]any, 0, limit)
	for i := offset; i < total && len(events) < limit; i++ {
		ev := ordered[i]
		events = append(events, map[string]any{
			"id":         ev.id,
			"type":       ev.eventType,
			"email":      ev.email,
			"principal":  emptyToNil(ev.principal),
			"detail":     ev.detail,
			"occurredAt": ev.at.UTC().Format(time.RFC3339),
		})
	}
	writeScimAdminJSON(c, http.StatusOK, map[string]any{
		"events": events,
		"total":  total,
		"limit":  limit,
		"offset": offset,
	})
}

// quarantine returns the review queue. The push receiver does not currently
// quarantine (ambiguous-identity handling is a planned enhancement), so the
// queue is empty; the endpoint exists so the dashboard renders and resolve is
// wired for when quarantine lands.
func (a *scimAdminHandler) quarantine(c *gin.Context) {
	writeScimAdminJSON(c, http.StatusOK, map[string]any{"items": []any{}, "total": 0})
}

func (a *scimAdminHandler) resolveQuarantine(c *gin.Context) {
	writeScimAdminErr(c, scimErr(http.StatusNotFound, "quarantine item not found", ""))
}

// mintTokenHandler ensures the dedicated SCIM service user exists and mints a
// single bearer token for it, returned in plaintext exactly once. The operator
// pastes this token into the identity provider. The token is owned by the service
// user, so SCIM writes run with that principal's permissions (RunAs).
func (a *scimAdminHandler) mintTokenHandler(c *gin.Context) {
	reqCtx := c.Request.Context()
	caller := a.callerSession(c)
	if caller == nil {
		writeScimAdminErr(c, scimErr(http.StatusUnauthorized, "authentication required", ""))
		return
	}

	serviceID, err := a.ensureServiceUser(reqCtx, caller)
	if err != nil {
		writeScimAdminErr(c, err)
		return
	}
	token, err := a.mintToken(reqCtx, serviceID, caller.Scope)
	if err != nil {
		writeScimAdminErr(c, err)
		return
	}
	writeScimAdminJSON(c, http.StatusCreated, map[string]any{
		"token":        token,
		"serviceUser":  a.cfg().serviceUsername,
		"tokenName":    scimTokenName,
		"instructions": "Paste this token as the SCIM bearer token in your identity provider. It is shown once and cannot be retrieved again.",
	})
}

func (a *scimAdminHandler) ensureServiceUser(ctx context.Context, caller *models.UserSession) (string, error) {
	cfg := a.cfg()
	// The service profile MUST be configured before minting — checked first, so a
	// pre-existing account can never become the RunAs principal without it.
	if strings.TrimSpace(cfg.serviceProfile) == "" {
		return "", scimErr(http.StatusInternalServerError, "SCIM service profile is not configured in the L2 pack", "")
	}
	profile, err := a.records().RetrieveByField(ctx, scimProfileObject, scimFieldName, cfg.serviceProfile, caller)
	if err != nil {
		return "", err
	}
	if profile == nil {
		return "", scimErr(http.StatusInternalServerError, "SCIM service profile not found: "+cfg.serviceProfile, "")
	}
	profileID := recStr(profile, scimFieldID)
	if profileID == "" {
		return "", scimErr(http.StatusInternalServerError, "SCIM service profile has no id", "")
	}

	existing, err := a.records().RetrieveByField(ctx, scimUserObject, scim.FieldUsername, cfg.serviceUsername, caller)
	if err != nil {
		return "", err
	}
	if existing != nil {
		id := recStr(existing, scimFieldID)
		if id == "" {
			return "", scimErr(http.StatusInternalServerError, "existing SCIM service user has no id", "")
		}
		// Refuse to bind SCIM to a pre-existing account whose profile is not the
		// configured (least-privilege) one — otherwise someone could pre-create
		// the service username with a privileged profile and hijack the RunAs.
		if recStr(existing, scim.FieldProfileID) != profileID {
			return "", scimErr(http.StatusInternalServerError, "existing SCIM service user does not have the configured service profile; refusing to mint", "")
		}
		// The profile is only the BASELINE grant. Permission-set assignments and
		// group memberships add privileges ON TOP of the profile, so a
		// pre-existing account at the service username could carry elevated
		// grants the profile check alone would miss. An account created by THIS
		// flow has neither, so refuse to reuse any account that has either — a
		// SCIM bearer must never inherit privileges beyond the least-privilege
		// service profile.
		if err := a.refuseElevatedGrants(ctx, id, caller); err != nil {
			return "", err
		}
		// A deactivated service account would make the minted token unusable
		// (token validation rejects an inactive owner) with no way to recover
		// through this endpoint. The mint is the admin-facing repair path, so
		// reactivate the account here (caller holds ManageUsers).
		if !recBool(existing, scim.FieldIsActive) {
			if err := a.records().Update(ctx, scimUserObject, id, contract.Record{scim.FieldIsActive: true}, caller); err != nil {
				return "", err
			}
		}
		return id, nil
	}

	data := contract.Record{
		scim.FieldUsername:  cfg.serviceUsername,
		scim.FieldEmail:     cfg.serviceUsername,
		scim.FieldLastName:  "SCIM Provisioning",
		scim.FieldAlias:     deriveAlias(cfg.serviceUsername),
		scim.FieldProfileID: profileID,
		scim.FieldIsActive:  true,
	}
	// Locale/timezone only when explicitly configured; otherwise the platform
	// User metadata defaults apply when the L2 record leaves locale fields unset.
	applyScimLocaleConfig(data, cfg)
	created, err := a.records().Insert(ctx, scimUserObject, data, caller)
	if err != nil {
		return "", err
	}
	id := recStr(created, scimFieldID)
	if id == "" {
		return "", scimErr(http.StatusInternalServerError, "created SCIM service user has no id", "")
	}
	return id, nil
}

func emptyToNil(s string) any {
	if strings.TrimSpace(s) == "" {
		return nil
	}
	return s
}
