package rest

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/rest/respond"
	"github.com/pingcap/agent-git-service/internal/service"
)

type importBatchRequest struct {
	BatchID       string `json:"batch_id"`
	ObjectType    string `json:"object_type"`
	SchemaVersion int    `json:"schema_version"`
	SourceCursor  string `json:"source_cursor"`
	Checksum      string `json:"checksum"`
	Items         []struct {
		SourceID     string          `json:"source_id"`
		SourceNodeID string          `json:"source_node_id"`
		SourceNumber *int            `json:"source_number"`
		SourceURL    string          `json:"source_url"`
		Payload      json.RawMessage `json:"payload"`
	} `json:"items"`
}

type applyImportRequest struct {
	BatchLimit int  `json:"batch_limit"`
	SkipVerify bool `json:"skip_verify"`
}

type importSourceUserMappingRequest struct {
	Mappings []struct {
		SourceUserID    uint   `json:"source_user_id"`
		TargetUserLogin string `json:"target_user_login"`
		SkipInvitation  bool   `json:"skip_invitation"`
	} `json:"mappings"`
}

type importAttributionInvitationResponseRequest struct {
	Accept bool `json:"accept"`
}

// UploadImportReleaseAsset handles PUT
// /api/ext/v1/imports/{import_id}/release-assets/{source_id}. The body is raw
// binary; release assets must not be Base64-encoded into an import-batch JSON
// request because that would exceed normal API limits for valid assets.
func (d *Deps) UploadImportReleaseAsset(w http.ResponseWriter, r *http.Request) {
	importID, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, importID, service.ImportJobPermissionManage); !ok {
		return
	}
	sourceID := strings.TrimSpace(pathParam(r, "source_id"))
	size, err := strconv.ParseInt(strings.TrimSpace(r.Header.Get("X-Import-Asset-Size")), 10, 64)
	if err != nil || size < 0 {
		respond.ValidationFailed(w, "X-Import-Asset-Size must be a non-negative integer")
		return
	}
	asset, err := d.Svc.StageImportReleaseAsset(r.Context(), service.StageImportReleaseAssetInput{
		ImportJobID: importID,
		SourceID:    sourceID,
		Size:        size,
		Checksum:    r.Header.Get("X-Import-Asset-Checksum"),
	}, r.Body)
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	respond.JSON(w, http.StatusCreated, map[string]any{
		"id":        asset.ID,
		"import_id": asset.ImportJobID,
		"source_id": asset.SourceID,
		"size":      asset.Size,
		"checksum":  asset.Checksum,
	})
}

// CreateGitHubImport handles POST /api/ext/v1/imports/github.
func (d *Deps) CreateGitHubImport(w http.ResponseWriter, r *http.Request) {
	initiator, ok := d.requireAuthenticatedUser(w, r)
	if !ok {
		return
	}

	var req struct {
		SourceOwner      string                                 `json:"source_owner"`
		SourceRepo       string                                 `json:"source_repo"`
		SourceAPIURL     string                                 `json:"source_api_url"`
		SourceHTMLURL    string                                 `json:"source_html_url"`
		TargetOwner      string                                 `json:"target_owner"`
		TargetRepo       string                                 `json:"target_repo"`
		TargetMode       string                                 `json:"target_mode"`
		TargetRepository *service.ImportTargetRepositoryOptions `json:"target_repository"`
		Options          service.GitHubImportOptions            `json:"options"`
	}
	if err := decodeBodyStrict(r, &req); err != nil {
		respond.ValidationFailed(w, "invalid JSON")
		return
	}
	input := service.CreateGitHubImportInput{
		SourceOwner:   req.SourceOwner,
		SourceRepo:    req.SourceRepo,
		SourceAPIURL:  req.SourceAPIURL,
		SourceHTMLURL: req.SourceHTMLURL,
		TargetOwner:   req.TargetOwner,
		TargetRepo:    req.TargetRepo,
		TargetMode:    req.TargetMode,
		InitiatorID:   initiator.ID,
		Options:       req.Options,
	}
	if req.TargetRepository != nil {
		input.TargetRepository = *req.TargetRepository
		input.TargetRepositorySet = true
	}
	job, err := d.Svc.CreateGitHubImportJob(r.Context(), input)
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	respond.JSON(w, http.StatusCreated, d.importJobJSON(r.Context(), job))
}

// GetImport handles GET /api/ext/v1/imports/{import_id}.
func (d *Deps) GetImport(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	job, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionRead)
	if !ok {
		return
	}
	respond.JSON(w, http.StatusOK, d.importJobJSON(r.Context(), job))
}

// CancelImport handles POST /api/ext/v1/imports/{import_id}/cancel.
func (d *Deps) CancelImport(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionManage); !ok {
		return
	}
	job, err := d.Svc.CancelImportJob(r.Context(), id)
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	respond.JSON(w, http.StatusOK, d.importJobJSON(r.Context(), job))
}

// ListImportLogs handles GET /api/ext/v1/imports/{import_id}/logs.
func (d *Deps) ListImportLogs(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionRead); !ok {
		return
	}
	logs, err := d.Svc.ListImportLogs(r.Context(), id, importLimit(r))
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	out := make([]any, 0, len(logs))
	for _, log := range logs {
		out = append(out, importLogJSON(log))
	}
	respond.JSON(w, http.StatusOK, out)
}

// ListImportMappings handles GET /api/ext/v1/imports/{import_id}/mapping.
func (d *Deps) ListImportMappings(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionRead); !ok {
		return
	}
	mappings, err := d.Svc.ListImportMappingsByTargetTypeAfter(r.Context(), id, importAfterID(r), r.URL.Query().Get("target_type"), importLimit(r))
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	out := make([]any, 0, len(mappings))
	for _, mapping := range mappings {
		out = append(out, importMappingJSON(mapping))
	}
	respond.JSON(w, http.StatusOK, out)
}

// ListImportSourceUsers handles GET /api/ext/v1/imports/{import_id}/source-users.
func (d *Deps) ListImportSourceUsers(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionRead); !ok {
		return
	}
	users, err := d.Svc.ListImportSourceUsersAfter(r.Context(), id, importAfterID(r), importLimit(r))
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	out := make([]any, 0, len(users))
	for _, user := range users {
		out = append(out, importSourceUserJSON(user))
	}
	respond.JSON(w, http.StatusOK, out)
}

// MapImportSourceUsers handles PATCH /api/ext/v1/imports/{import_id}/source-users.
// It creates attribution invitations by default. The selected AGS member must
// accept before historical activity is reattributed.
func (d *Deps) MapImportSourceUsers(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionManage); !ok {
		return
	}
	var req importSourceUserMappingRequest
	if err := decodeBodyStrict(r, &req); err != nil {
		respond.ValidationFailed(w, "invalid JSON")
		return
	}
	inputs := make([]service.ImportSourceUserMappingInput, 0, len(req.Mappings))
	for _, mapping := range req.Mappings {
		inputs = append(inputs, service.ImportSourceUserMappingInput{
			SourceUserID: mapping.SourceUserID, TargetUserLogin: mapping.TargetUserLogin, SkipInvitation: mapping.SkipInvitation,
		})
	}
	mapped, err := d.Svc.MapImportSourceUsers(r.Context(), id, inputs)
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	out := make([]any, 0, len(mapped))
	for _, user := range mapped {
		out = append(out, importSourceUserJSON(user))
	}
	respond.JSON(w, http.StatusOK, out)
}

// ListMyClaimableImportSourceUsers handles GET /api/ext/v1/user/import-source-users/pending.
func (d *Deps) ListMyClaimableImportSourceUsers(w http.ResponseWriter, r *http.Request) {
	viewer, err := d.Svc.GetCurrentUser(r.Context())
	if err != nil {
		respond.Error(w, http.StatusUnauthorized, "Bad credentials")
		return
	}
	users, err := d.Svc.ListClaimableImportSourceUsers(r.Context(), viewer.ID, importLimit(r))
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	out := make([]any, 0, len(users))
	for _, user := range users {
		out = append(out, importSourceUserJSON(user))
	}
	respond.JSON(w, http.StatusOK, out)
}

// ListMyImportAttributionInvitations handles GET /api/ext/v1/user/import-attribution-invitations.
func (d *Deps) ListMyImportAttributionInvitations(w http.ResponseWriter, r *http.Request) {
	viewer, err := d.Svc.GetCurrentUser(r.Context())
	if err != nil {
		respond.Error(w, http.StatusUnauthorized, "Bad credentials")
		return
	}
	users, err := d.Svc.ListMyImportAttributionInvitations(r.Context(), viewer.ID, importLimit(r))
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	out := make([]any, 0, len(users))
	for _, user := range users {
		out = append(out, importSourceUserJSON(user))
	}
	respond.JSON(w, http.StatusOK, out)
}

// ClaimImportSourceUser handles POST /api/ext/v1/imports/{import_id}/source-users/{source_user_id}/claim.
func (d *Deps) ClaimImportSourceUser(w http.ResponseWriter, r *http.Request) {
	viewer, err := d.Svc.GetCurrentUser(r.Context())
	if err != nil {
		respond.Error(w, http.StatusUnauthorized, "Bad credentials")
		return
	}
	importID, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	sourceUserID, ok := mustUintParam(w, r, "source_user_id")
	if !ok {
		return
	}
	claimed, err := d.Svc.ClaimImportSourceUser(r.Context(), importID, sourceUserID, viewer.ID)
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	respond.JSON(w, http.StatusOK, importSourceUserJSON(claimed))
}

// RespondToImportAttributionInvitation handles POST
// /api/ext/v1/imports/{import_id}/source-users/{source_user_id}/invitation.
func (d *Deps) RespondToImportAttributionInvitation(w http.ResponseWriter, r *http.Request) {
	viewer, err := d.Svc.GetCurrentUser(r.Context())
	if err != nil {
		respond.Error(w, http.StatusUnauthorized, "Bad credentials")
		return
	}
	importID, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	sourceUserID, ok := mustUintParam(w, r, "source_user_id")
	if !ok {
		return
	}
	var req importAttributionInvitationResponseRequest
	if err := decodeBodyStrict(r, &req); err != nil {
		respond.ValidationFailed(w, "invalid JSON")
		return
	}
	updated, err := d.Svc.RespondToImportAttributionInvitation(r.Context(), importID, sourceUserID, viewer.ID, req.Accept)
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	respond.JSON(w, http.StatusOK, importSourceUserJSON(updated))
}

// CreateImportBatch handles POST /api/ext/v1/imports/{import_id}/batches.
func (d *Deps) CreateImportBatch(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionManage); !ok {
		return
	}
	var req importBatchRequest
	if err := decodeBodyStrict(r, &req); err != nil {
		respond.ValidationFailed(w, "invalid JSON")
		return
	}
	batch, err := d.Svc.CreateImportBatch(r.Context(), importBatchInputFromRequest(id, req))
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	respond.JSON(w, http.StatusCreated, importBatchJSON(batch))
}

// CreateImportBatches handles POST /api/ext/v1/imports/{import_id}/batches/bulk.
func (d *Deps) CreateImportBatches(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionManage); !ok {
		return
	}
	var req struct {
		Batches []importBatchRequest `json:"batches"`
	}
	if err := decodeBodyStrict(r, &req); err != nil {
		respond.ValidationFailed(w, "invalid JSON")
		return
	}
	inputs := make([]service.CreateImportBatchInput, 0, len(req.Batches))
	for _, batch := range req.Batches {
		inputs = append(inputs, importBatchInputFromRequest(id, batch))
	}
	batches, err := d.Svc.CreateImportBatches(r.Context(), inputs)
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	out := make([]any, 0, len(batches))
	for _, batch := range batches {
		out = append(out, importBatchJSON(batch))
	}
	respond.JSON(w, http.StatusCreated, map[string]any{
		"count":   len(out),
		"batches": out,
	})
}

func importBatchInputFromRequest(importID uint, req importBatchRequest) service.CreateImportBatchInput {
	items := make([]service.ImportBatchItemInput, 0, len(req.Items))
	for _, item := range req.Items {
		items = append(items, service.ImportBatchItemInput{
			SourceID:     item.SourceID,
			SourceNodeID: item.SourceNodeID,
			SourceNumber: item.SourceNumber,
			SourceURL:    item.SourceURL,
			PayloadJSON:  string(item.Payload),
		})
	}
	return service.CreateImportBatchInput{
		ImportJobID:   importID,
		BatchID:       req.BatchID,
		ObjectType:    req.ObjectType,
		SchemaVersion: req.SchemaVersion,
		SourceCursor:  req.SourceCursor,
		Checksum:      req.Checksum,
		Items:         items,
	}
}

// ListImportBatches handles GET /api/ext/v1/imports/{import_id}/batches.
func (d *Deps) ListImportBatches(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionRead); !ok {
		return
	}
	batches, err := d.Svc.ListImportBatches(r.Context(), id, importLimit(r))
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	out := make([]any, 0, len(batches))
	for _, batch := range batches {
		out = append(out, importBatchJSON(batch))
	}
	respond.JSON(w, http.StatusOK, out)
}

// ApplyImport handles POST /api/ext/v1/imports/{import_id}/apply.
func (d *Deps) ApplyImport(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionManage); !ok {
		return
	}
	var req applyImportRequest
	if err := decodeBodyStrictOptional(r, &req); err != nil {
		respond.ValidationFailed(w, "invalid JSON")
		return
	}
	if raw := strings.TrimSpace(r.URL.Query().Get("batch_limit")); raw != "" {
		n, err := strconv.Atoi(raw)
		if err != nil {
			respond.ValidationFailed(w, "batch_limit must be an integer")
			return
		}
		req.BatchLimit = n
	}
	if raw := strings.TrimSpace(r.URL.Query().Get("skip_verify")); raw != "" {
		value, err := strconv.ParseBool(raw)
		if err != nil {
			respond.ValidationFailed(w, "skip_verify must be a boolean")
			return
		}
		req.SkipVerify = value
	}
	if req.BatchLimit < 0 {
		respond.ValidationFailed(w, "batch_limit must be greater than or equal to 0")
		return
	}
	report, err := d.Svc.ApplyImportBatchesWithOptions(r.Context(), id, service.ApplyImportBatchesOptions{
		BatchLimit: req.BatchLimit,
		SkipVerify: req.SkipVerify,
	})
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	respond.JSON(w, http.StatusOK, report)
}

// VerifyImport handles GET /api/ext/v1/imports/{import_id}/verify.
func (d *Deps) VerifyImport(w http.ResponseWriter, r *http.Request) {
	id, ok := mustUintParam(w, r, "import_id")
	if !ok {
		return
	}
	if _, ok := d.requireImportJobPermission(w, r, id, service.ImportJobPermissionRead); !ok {
		return
	}
	report, err := d.Svc.VerifyImportJob(r.Context(), id)
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return
	}
	respond.JSON(w, http.StatusOK, report)
}

func (d *Deps) requireAuthenticatedUser(w http.ResponseWriter, r *http.Request) (db.User, bool) {
	user, err := d.Svc.GetCurrentUser(r.Context())
	if err != nil {
		respond.Error(w, http.StatusUnauthorized, "Bad credentials")
		return db.User{}, false
	}
	return user, true
}

func (d *Deps) requireImportJobPermission(w http.ResponseWriter, r *http.Request, importID uint, permission service.ImportJobPermission) (db.ImportJob, bool) {
	viewer, ok := d.requireAuthenticatedUser(w, r)
	if !ok {
		return db.ImportJob{}, false
	}
	job, err := d.Svc.AuthorizeImportJob(r.Context(), importID, viewer.ID, permission)
	if err != nil {
		respond.ServiceErrorRequest(r, w, err)
		return db.ImportJob{}, false
	}
	return job, true
}

func (d *Deps) importJobJSON(ctx context.Context, job db.ImportJob) map[string]any {
	target := map[string]any{
		"owner": job.TargetOwner,
		"repo":  job.TargetRepo,
	}
	if job.TargetRepoID != nil {
		target["repository_id"] = *job.TargetRepoID
	}

	return map[string]any{
		"id":            job.ID,
		"source":        job.Source,
		"status":        job.Status,
		"stage":         job.Stage,
		"target_mode":   job.TargetMode,
		"dry_run":       job.DryRun,
		"mirror_git":    job.MirrorGit,
		"source_repo":   map[string]any{"owner": job.SourceOwner, "repo": job.SourceRepo, "api_url": job.SourceAPIURL, "html_url": job.SourceHTMLURL},
		"target_repo":   target,
		"initiator":     userLoginOrNil(job.Initiator),
		"options":       jsonObject(job.OptionsJSON),
		"inventory":     jsonObject(job.InventoryJSON),
		"error":         string(job.ErrorMessage),
		"created_at":    job.CreatedAt.Format(time.RFC3339),
		"updated_at":    job.UpdatedAt.Format(time.RFC3339),
		"started_at":    formatTimePtr(job.StartedAt),
		"completed_at":  formatTimePtr(job.CompletedAt),
		"url":           d.importURL(ctx, job.ID),
		"logs_url":      d.importURL(ctx, job.ID) + "/logs",
		"mapping_url":   d.importURL(ctx, job.ID) + "/mapping",
		"cancel_url":    d.importURL(ctx, job.ID) + "/cancel",
		"documentation": "docs/design/github-import-mode.md",
	}
}

func importLogJSON(log db.ImportLog) map[string]any {
	return map[string]any{
		"id":         log.ID,
		"level":      log.Level,
		"stage":      log.Stage,
		"message":    log.Message,
		"details":    jsonObject(log.DetailsJSON),
		"created_at": log.CreatedAt.Format(time.RFC3339),
	}
}

func importMappingJSON(mapping db.ImportItemMapping) map[string]any {
	return map[string]any{
		"id":             mapping.ID,
		"source_type":    mapping.SourceType,
		"source_id":      mapping.SourceID,
		"source_node_id": mapping.SourceNodeID,
		"source_number":  mapping.SourceNumber,
		"source_url":     mapping.SourceURL,
		"target_type":    mapping.TargetType,
		"target_id":      mapping.TargetID,
		"target_number":  mapping.TargetNumber,
		"target_url":     mapping.TargetURL,
		"metadata":       jsonObject(mapping.MetadataJSON),
		"created_at":     mapping.CreatedAt.Format(time.RFC3339),
		"updated_at":     mapping.UpdatedAt.Format(time.RFC3339),
	}
}

func importSourceUserJSON(source db.ImportSourceUser) map[string]any {
	var target any
	if source.TargetUserID != nil {
		target = userLoginOrNil(source.TargetUser)
	}
	var proposed any
	if source.ProposedUserID != nil {
		proposed = userLoginOrNil(source.ProposedUser)
	}
	return map[string]any{
		"id":                source.ID,
		"import_id":         source.ImportJobID,
		"source_login":      source.SourceLogin,
		"source_id":         source.SourceID,
		"source_url":        source.SourceURL,
		"placeholder_login": source.PlaceholderLogin,
		"state":             source.State,
		"target_user":       target,
		"proposed_user":     proposed,
		"invitation_state":  source.InvitationState,
		"invited_at":        formatTimePtr(source.InvitedAt),
		"responded_at":      formatTimePtr(source.RespondedAt),
		"metadata":          jsonObject(source.MetadataJSON),
		"created_at":        source.CreatedAt.Format(time.RFC3339),
		"updated_at":        source.UpdatedAt.Format(time.RFC3339),
	}
}

func importBatchJSON(batch db.ImportBatch) map[string]any {
	return map[string]any{
		"id":             batch.ID,
		"batch_id":       batch.BatchID,
		"object_type":    batch.ObjectType,
		"schema_version": batch.SchemaVersion,
		"source_cursor":  batch.SourceCursor,
		"item_count":     batch.ItemCount,
		"checksum":       batch.Checksum,
		"status":         batch.Status,
		"error":          string(batch.ErrorMessage),
		"created_at":     batch.CreatedAt.Format(time.RFC3339),
		"updated_at":     batch.UpdatedAt.Format(time.RFC3339),
		"applied_at":     formatTimePtr(batch.AppliedAt),
	}
}

func (d *Deps) importURL(ctx context.Context, id uint) string {
	return strings.TrimRight(d.Svc.CanonicalBaseURL(ctx), "/") + fmt.Sprintf("/api/ext/v1/imports/%d", id)
}

func importLimit(r *http.Request) int {
	limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
	if err != nil {
		return 100
	}
	return limit
}

func importAfterID(r *http.Request) uint {
	after, err := strconv.ParseUint(r.URL.Query().Get("after_id"), 10, 64)
	if err != nil || after == 0 {
		return 0
	}
	return uint(after)
}

func jsonObject(raw db.LargeText) any {
	text := strings.TrimSpace(string(raw))
	if text == "" {
		return nil
	}
	var out any
	if err := json.Unmarshal([]byte(text), &out); err != nil {
		return text
	}
	return out
}

func userLoginOrNil(user db.User) any {
	if user.ID == 0 {
		return nil
	}
	return map[string]any{
		"id":    user.ID,
		"login": user.Login,
		"type":  user.Type,
	}
}

func formatTimePtr(ts *time.Time) any {
	if ts == nil {
		return nil
	}
	return ts.Format(time.RFC3339)
}
