// api_users.go — JSON handlers for the user-management surface.
//
// All endpoints in this file are admin-only; gating happens at the
// route-mount site (server.go's admin band).
package http

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"log/slog"
	stdhttp "net/http"
	"net/mail"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/oklog/ulid/v2"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

type listUsersResponse struct {
	Users []apiUser `json:"users"`
}

type apiUser struct {
	ID                 string  `json:"id"`
	Username           string  `json:"username"`
	Role               string  `json:"role"`
	Email              *string `json:"email,omitempty"`
	Disabled           bool    `json:"disabled"`
	MustChangePassword bool    `json:"must_change_password"`
	CreatedAt          string  `json:"created_at"`
	LastLoginAt        *string `json:"last_login_at,omitempty"`
}

func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{})
	if err != nil {
		slog.Error("api users: list", "err", err)
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	out := make([]apiUser, len(users))
	for i, u := range users {
		var lastLogin *string
		if u.LastLoginAt != nil {
			s := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z")
			lastLogin = &s
		}
		out[i] = apiUser{
			ID: u.ID, Username: u.Username, Role: string(u.Role),
			Email: u.Email, Disabled: u.DisabledAt != nil,
			MustChangePassword: u.MustChangePassword,
			CreatedAt:          u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
			LastLoginAt:        lastLogin,
		}
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(listUsersResponse{Users: out})
}

type createUserRequest struct {
	Username string `json:"username"`
	Email    string `json:"email,omitempty"`
	Role     string `json:"role"`
}

type createUserResponse struct {
	ID       string `json:"id"`
	SetupURL string `json:"setup_url"`
}

// generateSetupToken returns 32 random bytes hex-encoded (64 chars).
func generateSetupToken() (string, error) {
	var b [32]byte
	if _, err := rand.Read(b[:]); err != nil {
		return "", err
	}
	return hex.EncodeToString(b[:]), nil
}

// validRole maps a wire role string to the typed constant. Returns
// ("", false) for anything unknown.
func validRole(r string) (store.Role, bool) {
	switch r {
	case "admin":
		return store.RoleAdmin, true
	case "operator":
		return store.RoleOperator, true
	case "viewer":
		return store.RoleViewer, true
	}
	return "", false
}

func (s *Server) handleAPIUserCreate(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	actor, _ := s.requireUser(r) // already gated by middleware
	var req createUserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
		return
	}
	uname := strings.ToLower(strings.TrimSpace(req.Username))
	if uname == "" {
		writeJSONError(w, stdhttp.StatusBadRequest, "username_required", "")
		return
	}
	role, ok := validRole(req.Role)
	if !ok {
		writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "")
		return
	}
	if req.Email != "" {
		if _, err := mail.ParseAddress(req.Email); err != nil {
			writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error())
			return
		}
	}

	// Check for collision against existing user (case-insensitive).
	existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname)
	if err == nil {
		body := map[string]any{
			"error":            "username_taken",
			"existing_user_id": existing.ID,
			"disabled":         existing.DisabledAt != nil,
		}
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		w.WriteHeader(stdhttp.StatusConflict)
		_ = json.NewEncoder(w).Encode(body)
		return
	} else if !errors.Is(err, store.ErrNotFound) {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}

	id := ulid.Make().String()
	now := time.Now().UTC()
	var emailPtr *string
	if req.Email != "" {
		em := strings.ToLower(strings.TrimSpace(req.Email))
		emailPtr = &em
	}
	if err := s.deps.Store.CreateUser(r.Context(), store.User{
		ID: id, Username: uname, PasswordHash: "",
		Role: role, Email: emailPtr, CreatedAt: now,
		MustChangePassword: true,
	}); err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}

	rawToken, err := generateSetupToken()
	if err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	var actorID *string
	if actor != nil {
		actorID = &actor.ID
	}
	if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
		UserID: id, TokenHash: hashSetupToken(rawToken),
		ExpiresAt: now.Add(time.Hour),
		CreatedAt: now, CreatedBy: actorID,
	}); err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: actorID, Actor: "user",
		Action: "user.created", TargetKind: ptr("user"), TargetID: &id,
		TS: now,
	})

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(stdhttp.StatusCreated)
	_ = json.NewEncoder(w).Encode(createUserResponse{
		ID:       id,
		SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
	})
}

func (s *Server) handleAPIUserGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	id := chi.URLParam(r, "id")
	u, err := s.deps.Store.GetUserByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
			return
		}
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	out := apiUser{
		ID: u.ID, Username: u.Username, Role: string(u.Role),
		Email: u.Email, Disabled: u.DisabledAt != nil,
		MustChangePassword: u.MustChangePassword,
		CreatedAt:          u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
	}
	if u.LastLoginAt != nil {
		ll := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z")
		out.LastLoginAt = &ll
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(out)
}

type patchUserRequest struct {
	Role  *string `json:"role,omitempty"`
	Email *string `json:"email,omitempty"`
}

func (s *Server) handleAPIUserPatch(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	actor, _ := s.requireUser(r)
	id := chi.URLParam(r, "id")
	u, err := s.deps.Store.GetUserByID(r.Context(), id)
	if err != nil {
		writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
		return
	}
	var req patchUserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
		return
	}
	if req.Role != nil {
		newRole, ok := validRole(*req.Role)
		if !ok {
			writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "")
			return
		}
		// Last-admin guard: cannot demote the only enabled admin.
		if u.Role == store.RoleAdmin && newRole != store.RoleAdmin && u.DisabledAt == nil {
			n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
			if n <= 1 {
				writeJSONError(w, stdhttp.StatusConflict, "last_admin", "")
				return
			}
		}
		if err := s.deps.Store.SetUserRole(r.Context(), id, newRole); err != nil {
			writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
			return
		}
	}
	if req.Email != nil {
		em := strings.TrimSpace(*req.Email)
		if em != "" {
			if _, err := mail.ParseAddress(em); err != nil {
				writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error())
				return
			}
		}
		if err := s.deps.Store.SetUserEmail(r.Context(), id, em); err != nil {
			writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
			return
		}
	}
	var actorID *string
	if actor != nil {
		actorID = &actor.ID
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: actorID, Actor: "user",
		Action: "user.updated", TargetKind: ptr("user"), TargetID: &id,
		TS: time.Now().UTC(),
	})
	w.WriteHeader(stdhttp.StatusOK)
}

func (s *Server) handleAPIUserDisable(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	actor, _ := s.requireUser(r)
	id := chi.URLParam(r, "id")
	u, err := s.deps.Store.GetUserByID(r.Context(), id)
	if err != nil {
		writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
		return
	}
	if u.Role == store.RoleAdmin && u.DisabledAt == nil {
		n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
		if n <= 1 {
			writeJSONError(w, stdhttp.StatusConflict, "last_admin", "")
			return
		}
	}
	now := time.Now().UTC()
	if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	// Kick existing sessions so the user is bounced immediately.
	_, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
	var actorID *string
	if actor != nil {
		actorID = &actor.ID
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: actorID, Actor: "user",
		Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id,
		TS: now,
	})
	w.WriteHeader(stdhttp.StatusOK)
}

func (s *Server) handleAPIUserEnable(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	actor, _ := s.requireUser(r)
	id := chi.URLParam(r, "id")
	if err := s.deps.Store.EnableUser(r.Context(), id); err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	var actorID *string
	if actor != nil {
		actorID = &actor.ID
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: actorID, Actor: "user",
		Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id,
		TS: time.Now().UTC(),
	})
	w.WriteHeader(stdhttp.StatusOK)
}

type regenerateSetupResponse struct {
	SetupURL string `json:"setup_url"`
}

func (s *Server) handleAPIUserRegenerateSetup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	actor, _ := s.requireUser(r)
	id := chi.URLParam(r, "id")
	if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil {
		writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
		return
	}
	rawToken, err := generateSetupToken()
	if err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	now := time.Now().UTC()
	var actorID *string
	if actor != nil {
		actorID = &actor.ID
	}
	if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
		UserID: id, TokenHash: hashSetupToken(rawToken),
		ExpiresAt: now.Add(time.Hour),
		CreatedAt: now, CreatedBy: actorID,
	}); err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	if err := s.deps.Store.SetMustChangePassword(r.Context(), id, true); err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: actorID, Actor: "user",
		Action:     "user.setup_token.regenerated",
		TargetKind: ptr("user"), TargetID: &id, TS: now,
	})
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(regenerateSetupResponse{
		SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
	})
}

func (s *Server) handleAPIUserForceLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	actor, _ := s.requireUser(r)
	id := chi.URLParam(r, "id")
	n, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
	if err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
		return
	}
	var actorID *string
	if actor != nil {
		actorID = &actor.ID
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: actorID, Actor: "user",
		Action:     "user.force_logout",
		TargetKind: ptr("user"), TargetID: &id,
		TS: time.Now().UTC(),
	})
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(map[string]int64{"sessions_killed": n})
}
