// ui_users.go — Settings → Users HTML handlers (admin-only).
//
// Routes (wired in server.go's admin band):
//
//	GET  /settings/users                       → handleUIUsersList    (this task)
//	GET  /settings/users/new                   → F2
//	POST /settings/users/new                   → F2
//	GET  /settings/users/{id}/edit             → F3
//	POST /settings/users/{id}/edit             → F3
//	GET  /settings/users/{id}/setup-link       → F2
//	POST /settings/users/{id}/disable          → F3
//	POST /settings/users/{id}/enable           → F3
//	POST /settings/users/{id}/regenerate-setup → F3
//	POST /settings/users/{id}/force-logout     → F3
package http

import (
	"errors"
	"log/slog"
	stdhttp "net/http"
	"net/mail"
	"net/url"
	"strings"
	"time"

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

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

type usersPage struct {
	Users        []userRow
	ShowDisabled bool
	Sort         string // "username" | "email" | "role" | "last_login_at"
	Dir          string // "asc" | "desc"
	// SortHrefs is a fully-encoded /settings/users?…&sort=COL&dir=…
	// for each sortable column. Built server-side because constructing
	// the querystring inside <a href="…"> in html/template applies
	// URL-attribute escaping to '=' (turning 'show_disabled=1' into
	// 'show_disabled%3D1'), which silently drops every filter on click.
	// Same shape as the audit page's SortHrefs.
	SortHrefs map[string]string
}

type userRow struct {
	ID                 string
	Username           string
	Email              string
	Role               string
	LastLoginAt        string // pre-formatted "2006-01-02 15:04:05" or "never"
	Disabled           bool
	MustChangePassword bool
	AuthSource         string
}

func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	q := r.URL.Query()
	showDisabled := q.Get("show_disabled") == "1"

	// Resolve sort against the allowlist. Default: username ASC.
	resolvedSort := "username"
	switch q.Get("sort") {
	case "username", "email", "role", "last_login_at":
		resolvedSort = q.Get("sort")
	}
	asc := q.Get("dir") != "desc"
	if q.Get("sort") == "" {
		// No explicit sort param → default ASC even though dir
		// querystring might be missing (fresh page load).
		asc = true
	}
	dirStr := "desc"
	if asc {
		dirStr = "asc"
	}

	users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{
		OrderBy: resolvedSort, OrderAsc: asc,
	})
	if err != nil {
		slog.Error("ui users: list", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	rows := make([]userRow, 0, len(users))
	for _, ux := range users {
		if !showDisabled && ux.DisabledAt != nil {
			continue
		}
		em := ""
		if ux.Email != nil {
			em = *ux.Email
		}
		ll := "never"
		if ux.LastLoginAt != nil {
			ll = ux.LastLoginAt.UTC().Format("2006-01-02 15:04:05")
		}
		rows = append(rows, userRow{
			ID: ux.ID, Username: ux.Username, Email: em,
			Role: string(ux.Role), LastLoginAt: ll,
			Disabled:           ux.DisabledAt != nil,
			MustChangePassword: ux.MustChangePassword,
			AuthSource:         ux.AuthSource,
		})
	}

	// Pre-build per-column hrefs so the template just emits them.
	// Same pattern as ui_audit's SortHrefs — sidesteps html/template
	// URL-attribute escaping turning '=' into '%3D'.
	base := url.Values{}
	if showDisabled {
		base.Set("show_disabled", "1")
	}
	hrefs := make(map[string]string, 4)
	for _, col := range []string{"username", "email", "role", "last_login_at"} {
		v := url.Values{}
		for k, vs := range base {
			v[k] = vs
		}
		v.Set("sort", col)
		newDir := "asc" // sensible default for unactive columns
		if col == resolvedSort && asc {
			newDir = "desc"
		}
		v.Set("dir", newDir)
		hrefs[col] = "/settings/users?" + v.Encode()
	}

	view := s.baseView(r, u)
	view.Title = "Users · restic-manager"
	view.Active = "settings"
	view.Page = usersPage{
		Users: rows, ShowDisabled: showDisabled,
		Sort: resolvedSort, Dir: dirStr,
		SortHrefs: hrefs,
	}
	if err := s.deps.UI.Render(w, "users", view); err != nil {
		slog.Error("ui users: render", "err", err)
	}
}

type userFormPage struct {
	Mode       string // "new" | "edit" | "setup-link"
	ID         string
	Username   string
	Email      string
	Role       string
	Disabled   bool
	HasSetup   bool
	SetupURL   string
	SetupExpAt time.Time
	Error      string
	// Reenable is set when the admin landed here because they tried
	// to add a username that already exists (disabled). Triggers a
	// banner on the edit page explaining why and steering them at
	// the Re-enable button. See handleUIUserNewPost's collision branch.
	Reenable   bool
	AuthSource string
}

func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	view := s.baseView(r, u)
	view.Title = "New user · restic-manager"
	view.Active = "settings"
	view.Page = userFormPage{Mode: "new", Role: "operator"}
	_ = s.deps.UI.Render(w, "user_edit", view)
}

func (s *Server) handleUIUserNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	uname := strings.ToLower(strings.TrimSpace(r.PostForm.Get("username")))
	email := strings.TrimSpace(r.PostForm.Get("email"))
	role, ok := validRole(r.PostForm.Get("role"))
	if uname == "" || !ok {
		view := s.baseView(r, u)
		view.Title = "New user · restic-manager"
		view.Active = "settings"
		view.Page = userFormPage{
			Mode: "new", Username: uname, Email: email,
			Role:  r.PostForm.Get("role"),
			Error: "Username is required and role must be admin/operator/viewer.",
		}
		_ = s.deps.UI.Render(w, "user_edit", view)
		return
	}
	if email != "" {
		if _, err := mail.ParseAddress(email); err != nil {
			view := s.baseView(r, u)
			view.Title = "New user · restic-manager"
			view.Active = "settings"
			view.Page = userFormPage{
				Mode: "new", Username: uname, Email: email,
				Role:  r.PostForm.Get("role"),
				Error: "Email is not a valid address.",
			}
			_ = s.deps.UI.Render(w, "user_edit", view)
			return
		}
	}

	// Same collision logic as the API.
	existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname)
	if err == nil {
		if existing.DisabledAt != nil {
			// Punt the admin to the edit page where Re-enable is one click.
			stdhttp.Redirect(w, r, "/settings/users/"+existing.ID+
				"/edit?reenable=1", stdhttp.StatusSeeOther)
			return
		}
		view := s.baseView(r, u)
		view.Title = "New user · restic-manager"
		view.Active = "settings"
		view.Page = userFormPage{
			Mode: "new", Username: uname, Email: email,
			Role:  r.PostForm.Get("role"),
			Error: "A user with that name already exists.",
		}
		_ = s.deps.UI.Render(w, "user_edit", view)
		return
	} else if !errors.Is(err, store.ErrNotFound) {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	id := ulid.Make().String()
	now := time.Now().UTC()
	var emailPtr *string
	if email != "" {
		em := strings.ToLower(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 {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	rawToken, err := generateSetupToken()
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
		UserID: id, TokenHash: hashSetupToken(rawToken),
		ExpiresAt: now.Add(time.Hour),
		CreatedAt: now, CreatedBy: &u.ID,
	}); err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
		Action: "user.created", TargetKind: ptr("user"), TargetID: &id,
		TS: now,
	})
	stdhttp.Redirect(w, r,
		"/settings/users/"+id+"/setup-link?token="+rawToken,
		stdhttp.StatusSeeOther)
}

func (s *Server) handleUIUserEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	id := chi.URLParam(r, "id")
	target, err := s.deps.Store.GetUserByID(r.Context(), id)
	if err != nil {
		stdhttp.NotFound(w, r)
		return
	}
	em := ""
	if target.Email != nil {
		em = *target.Email
	}
	view := s.baseView(r, u)
	view.Title = "Edit user · restic-manager"
	view.Active = "settings"
	view.Page = userFormPage{
		Mode: "edit", ID: target.ID, Username: target.Username,
		Email: em, Role: string(target.Role),
		Disabled:   target.DisabledAt != nil,
		Reenable:   r.URL.Query().Get("reenable") == "1",
		AuthSource: target.AuthSource,
	}
	_ = s.deps.UI.Render(w, "user_edit", view)
}

func (s *Server) handleUIUserEditPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	id := chi.URLParam(r, "id")
	target, err := s.deps.Store.GetUserByID(r.Context(), id)
	if err != nil {
		stdhttp.NotFound(w, r)
		return
	}
	if target.AuthSource == "oidc" {
		stdhttp.Error(w, "OIDC users cannot have role/email edited locally", stdhttp.StatusForbidden)
		return
	}
	role, ok := validRole(r.PostForm.Get("role"))
	if !ok {
		stdhttp.Error(w, "bad role", stdhttp.StatusBadRequest)
		return
	}
	email := strings.TrimSpace(r.PostForm.Get("email"))
	if email != "" {
		if _, err := mail.ParseAddress(email); err != nil {
			stdhttp.Error(w, "bad email", stdhttp.StatusBadRequest)
			return
		}
	}
	if target.Role == store.RoleAdmin && role != store.RoleAdmin && target.DisabledAt == nil {
		n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
		if n <= 1 {
			stdhttp.Error(w, "cannot demote last admin", stdhttp.StatusConflict)
			return
		}
	}
	if err := s.deps.Store.SetUserRole(r.Context(), id, role); err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if err := s.deps.Store.SetUserEmail(r.Context(), id, email); err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
		Action: "user.updated", TargetKind: ptr("user"), TargetID: &id,
		TS: time.Now().UTC(),
	})
	stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther)
}

func (s *Server) handleUIUserDisablePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	id := chi.URLParam(r, "id")
	target, err := s.deps.Store.GetUserByID(r.Context(), id)
	if err != nil {
		stdhttp.NotFound(w, r)
		return
	}
	if target.Role == store.RoleAdmin && target.DisabledAt == nil {
		n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
		if n <= 1 {
			stdhttp.Error(w, "cannot disable last admin", stdhttp.StatusConflict)
			return
		}
	}
	now := time.Now().UTC()
	if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
		Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id,
		TS: now,
	})
	stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther)
}

func (s *Server) handleUIUserEnablePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	id := chi.URLParam(r, "id")
	if err := s.deps.Store.EnableUser(r.Context(), id); err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
		Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id,
		TS: time.Now().UTC(),
	})
	stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", stdhttp.StatusSeeOther)
}

func (s *Server) handleUIUserRegenerateSetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	id := chi.URLParam(r, "id")
	if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil {
		stdhttp.NotFound(w, r)
		return
	}
	rawToken, err := generateSetupToken()
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	now := time.Now().UTC()
	if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
		UserID: id, TokenHash: hashSetupToken(rawToken),
		ExpiresAt: now.Add(time.Hour), CreatedAt: now,
		CreatedBy: &u.ID,
	}); err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_ = s.deps.Store.SetMustChangePassword(r.Context(), id, true)
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
		Action:     "user.setup_token.regenerated",
		TargetKind: ptr("user"), TargetID: &id, TS: now,
	})
	stdhttp.Redirect(w, r,
		"/settings/users/"+id+"/setup-link?token="+rawToken,
		stdhttp.StatusSeeOther)
}

func (s *Server) handleUIUserForceLogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	id := chi.URLParam(r, "id")
	_, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
		Action:     "user.force_logout",
		TargetKind: ptr("user"), TargetID: &id,
		TS: time.Now().UTC(),
	})
	stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", stdhttp.StatusSeeOther)
}

func (s *Server) handleUIUserSetupLinkGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	id := chi.URLParam(r, "id")
	target, err := s.deps.Store.GetUserByID(r.Context(), id)
	if err != nil {
		stdhttp.NotFound(w, r)
		return
	}
	rawToken := r.URL.Query().Get("token")
	tok, err := s.deps.Store.GetSetupTokenByUserID(r.Context(), id)
	if err != nil || rawToken == "" {
		w.WriteHeader(stdhttp.StatusGone)
		view := s.baseView(r, u)
		view.Title = "Link expired · restic-manager"
		view.Active = "settings"
		view.Page = userFormPage{
			Mode: "setup-link", ID: target.ID, Username: target.Username,
			Error: "expired",
		}
		_ = s.deps.UI.Render(w, "user_edit", view)
		return
	}
	view := s.baseView(r, u)
	view.Title = "Setup link · restic-manager"
	view.Active = "settings"
	view.Page = userFormPage{
		Mode: "setup-link", ID: target.ID, Username: target.Username,
		Role: string(target.Role), HasSetup: true,
		SetupURL:   s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
		SetupExpAt: tok.ExpiresAt,
	}
	_ = s.deps.UI.Render(w, "user_edit", view)
}
