package auth

import (
	"context"
	"errors"

	"github.com/google/uuid"
	"go.uber.org/zap"
)

// ActorKind enumerates the principals that can drive a session revoke.
// The values are persisted into `session_revocations.actor_kind` and
// must stay aligned with `user.SessionRevokeActor*` constants and any
// admin/operator tooling that joins on the audit table.
type ActorKind string

const (
	// ActorKindUserSelf indicates the session's owner initiated the
	// revoke (logout self / logout-all-self through the user surface).
	ActorKindUserSelf ActorKind = "user_self"

	// ActorKindAdminSanction indicates an admin-applied sanction (most
	// notably permanent_block) caused the revoke.
	ActorKindAdminSanction ActorKind = "admin_sanction"

	// ActorKindSoftDeleteUser indicates the session's owner triggered
	// account soft-delete on themselves.
	ActorKindSoftDeleteUser ActorKind = "soft_delete_user"

	// ActorKindSoftDeleteAdmin indicates an admin soft-deleted the
	// account and the cascade revoked the sessions.
	ActorKindSoftDeleteAdmin ActorKind = "soft_delete_admin"
)

// RevokeContext records the audit metadata persisted alongside every
// session revoke. ActorID is the stable identifier of the principal (a
// user UUID for self-driven flows, an admin username for admin-driven
// flows). Reason is a free-form note kept verbatim.
type RevokeContext struct {
	ActorKind ActorKind
	ActorID   string
	Reason    string
}

// GetSession returns the active session keyed by deviceSessionID. The
// lookup hits the cache; on a miss the session is either revoked or
// absent. After a hit the call refreshes `last_seen_at` against
// Postgres so admin observers see when each cached session was last
// resolved by gateway. The refresh runs after the cache read and
// updates the cached row in-place; failures are logged but never block
// the lookup.
func (s *Service) GetSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, error) {
	if deviceSessionID == uuid.Nil {
		return Session{}, ErrSessionNotFound
	}
	sess, ok := s.deps.Cache.Get(deviceSessionID)
	if !ok {
		return Session{}, ErrSessionNotFound
	}
	now := s.deps.Now()
	if updated, err := s.deps.Store.TouchSessionLastSeen(ctx, deviceSessionID, now); err == nil {
		s.deps.Cache.Add(updated)
		return updated, nil
	} else if errors.Is(err, ErrSessionNotFound) {
		// The row vanished between Cache.Get and the touch — treat as
		// revoked from the caller's perspective.
		s.deps.Cache.Remove(deviceSessionID)
		return Session{}, ErrSessionNotFound
	} else {
		s.deps.Logger.Warn("auth: touch last_seen_at failed",
			zap.String("device_session_id", deviceSessionID.String()),
			zap.Error(err),
		)
		return sess, nil
	}
}

// ListActiveByUser returns the cached active sessions for userID. The
// user-surface "list my sessions" handler consumes this. The slice is
// safe for the caller to retain — it is freshly allocated.
func (s *Service) ListActiveByUser(_ context.Context, userID uuid.UUID) []Session {
	if userID == uuid.Nil {
		return nil
	}
	return s.deps.Cache.ListByUser(userID)
}

// LookupSessionInCache returns the cached session for deviceSessionID
// without touching last_seen_at. The user-surface revoke handler
// consumes this to verify ownership before issuing a revoke. A miss
// means the session is either revoked or absent — handlers must treat
// the two cases identically so a caller cannot probe whether a foreign
// device_session_id exists.
func (s *Service) LookupSessionInCache(deviceSessionID uuid.UUID) (Session, bool) {
	if deviceSessionID == uuid.Nil {
		return Session{}, false
	}
	return s.deps.Cache.Get(deviceSessionID)
}

// RevokeSession marks deviceSessionID revoked atomically with an
// audit row in `session_revocations`, evicts it from the cache, and
// emits a session_invalidation push event. The call is idempotent: a
// second revoke on an already-revoked session returns the existing
// row with status='revoked' (HTTP 200) and writes no audit row. An
// unknown device_session_id yields ErrSessionNotFound.
//
// Cache eviction and the push emission run after the database UPDATE
// commits so a failed UPDATE leaves both cache and gateway view
// intact.
func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID, rc RevokeContext) (Session, error) {
	if deviceSessionID == uuid.Nil {
		return Session{}, ErrSessionNotFound
	}
	revoked, ok, err := s.deps.Store.RevokeSession(ctx, deviceSessionID, rc, s.deps.Now())
	if err != nil {
		return Session{}, err
	}
	if ok {
		s.deps.Cache.Remove(deviceSessionID)
		s.deps.Push.PublishSessionInvalidation(ctx, deviceSessionID, revoked.UserID, string(rc.ActorKind))
		s.deps.Logger.Info("auth session revoked",
			zap.String("device_session_id", deviceSessionID.String()),
			zap.String("user_id", revoked.UserID.String()),
			zap.String("actor_kind", string(rc.ActorKind)),
			zap.String("actor_id", rc.ActorID),
		)
		return revoked, nil
	}
	// UPDATE matched no rows: the session is either already revoked or
	// never existed. Distinguish by reading the row directly so we can
	// return the idempotent revoked-shape rather than a 404 when the
	// session simply was revoked earlier.
	existing, err := s.deps.Store.LoadSession(ctx, deviceSessionID)
	if err != nil {
		if errors.Is(err, ErrSessionNotFound) {
			return Session{}, ErrSessionNotFound
		}
		return Session{}, err
	}
	return existing, nil
}

// RevokeAllForUser marks every active session for userID revoked
// atomically with one audit row per revoked session, evicts each from
// the cache, and emits one session_invalidation push event per
// revoked row. Returns the list of revoked sessions in the order
// Postgres returned them. An empty result is a successful idempotent
// call (handler reports revoked_count=0).
func (s *Service) RevokeAllForUser(ctx context.Context, userID uuid.UUID, rc RevokeContext) ([]Session, error) {
	if userID == uuid.Nil {
		return nil, nil
	}
	revoked, err := s.deps.Store.RevokeAllForUser(ctx, userID, rc, s.deps.Now())
	if err != nil {
		return nil, err
	}
	for _, sess := range revoked {
		s.deps.Cache.Remove(sess.DeviceSessionID)
		s.deps.Push.PublishSessionInvalidation(ctx, sess.DeviceSessionID, sess.UserID, string(rc.ActorKind))
	}
	if len(revoked) > 0 {
		s.deps.Logger.Info("auth sessions revoked (bulk)",
			zap.String("user_id", userID.String()),
			zap.Int("count", len(revoked)),
			zap.String("actor_kind", string(rc.ActorKind)),
			zap.String("actor_id", rc.ActorID),
		)
	}
	return revoked, nil
}
