// ui_enrollment_tokens.go — NS-02 token-recovery handlers.
//
// Today the only handle on a freshly-minted enrolment token is its
// /hosts/pending/{token} URL, which lives in the operator's browser
// tab. Closing that tab loses the install snippet — the row stays
// alive in the DB until TTL expiry but invisible to the UI. These
// handlers close the gap with two operations exposed on the
// Add-host page:
//
//	POST /hosts/enrollment-tokens/{hash}/regenerate
//	POST /hosts/enrollment-tokens/{hash}/revoke
//
// Hash here is the *token_hash* (sha256 hex of the raw token), which
// is opaque on its own — it is not the credential, just an identifier
// for the row. We chose regenerate over "show original token" because
// only hashes are persisted; the raw token has been gone since the
// original /hosts/new POST.
package http

import (
	"encoding/json"
	"errors"
	"log/slog"
	stdhttp "net/http"

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

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

// handleUIEnrollmentTokenRegenerate revokes the row keyed by token
// hash and mints a fresh raw token with the same attachments
// (encrypted repo creds, initial paths). Redirects to the new
// /hosts/pending/{newToken} so the operator lands directly on the
// install snippet.
func (s *Server) handleUIEnrollmentTokenRegenerate(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	user, ok := s.requireUser(r)
	if !ok {
		writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
		return
	}
	oldHash := chi.URLParam(r, "hash")
	if oldHash == "" {
		stdhttp.Error(w, "missing hash", stdhttp.StatusBadRequest)
		return
	}
	att, err := s.deps.Store.GetEnrollmentTokenAttachments(r.Context(), oldHash)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			// Already expired/consumed/revoked — bounce back without
			// fanfare so a stale form re-submit doesn't loud-fail.
			stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
			return
		}
		slog.Error("regen: load attachments", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	var blob repoCredsBlob
	if att.EncRepoCreds != "" {
		plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+oldHash))
		if err != nil {
			slog.Error("regen: decrypt", "err", err)
			stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
			return
		}
		_ = json.Unmarshal(plain, &blob)
	}

	// Mint the new row first; only revoke the old one once the fresh
	// row exists. If something fails between, the operator at worst
	// sees both rows side-by-side on the list page (and can revoke the
	// stale one manually) — much better than nuking the old row and
	// failing the mint, leaving them with nothing.
	newToken, _, err := s.mintEnrollmentToken(r.Context(),
		blob.RepoURL, blob.RepoUsername, blob.RepoPassword, att.InitialPaths)
	if err != nil {
		slog.Error("regen: mint new", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if err := s.deps.Store.DeleteEnrollmentToken(r.Context(), oldHash); err != nil &&
		!errors.Is(err, store.ErrNotFound) {
		slog.Warn("regen: delete old", "old_hash", oldHash, "err", err)
		// Fall through — the new row is good; operator can revoke the
		// stale row from the list if the orphan row bothers them.
	}

	uid := user.ID
	short := oldHash
	if len(short) > 12 {
		short = short[:12]
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID:         ulid.Make().String(),
		UserID:     &uid,
		Actor:      "user",
		Action:     "enrollment_token.regenerated",
		TargetKind: ptr("enrollment_token"),
		TargetID:   &short,
		TS:         nowUTC(),
	})

	stdhttp.Redirect(w, r, "/hosts/pending/"+newToken, stdhttp.StatusSeeOther)
}

// handleUIEnrollmentTokenRevoke deletes the token row outright.
// Redirects to /hosts/new where the list re-renders without the row.
func (s *Server) handleUIEnrollmentTokenRevoke(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	user, ok := s.requireUser(r)
	if !ok {
		writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
		return
	}
	hash := chi.URLParam(r, "hash")
	if hash == "" {
		stdhttp.Error(w, "missing hash", stdhttp.StatusBadRequest)
		return
	}
	if err := s.deps.Store.DeleteEnrollmentToken(r.Context(), hash); err != nil &&
		!errors.Is(err, store.ErrNotFound) {
		slog.Error("revoke: delete", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	uid := user.ID
	short := hash
	if len(short) > 12 {
		short = short[:12]
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID:         ulid.Make().String(),
		UserID:     &uid,
		Actor:      "user",
		Action:     "enrollment_token.revoked",
		TargetKind: ptr("enrollment_token"),
		TargetID:   &short,
		TS:         nowUTC(),
	})
	stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
}
