package cli

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"net/url"
	"sort"
	"strings"
	"time"

	"github.com/spf13/cobra"
)

type sessionOptions struct {
	project string
	json    bool
}

type sessionListOptions struct {
	sessionOptions
	all               bool
	includeTerminated bool
}

type sessionCleanupOptions struct {
	project string
	yes     bool
}

type sessionClaimPROptions struct {
	project    string
	json       bool
	noTakeover bool
}

type sessionRenameRequest struct {
	DisplayName string `json:"displayName"`
}

type sessionDTO struct {
	ID           string          `json:"id"`
	ProjectID    string          `json:"projectId"`
	IssueID      string          `json:"issueId,omitempty"`
	Kind         string          `json:"kind"`
	Harness      string          `json:"harness,omitempty"`
	DisplayName  string          `json:"displayName,omitempty"`
	Activity     sessionActivity `json:"activity"`
	IsTerminated bool            `json:"isTerminated"`
	CreatedAt    time.Time       `json:"createdAt"`
	UpdatedAt    time.Time       `json:"updatedAt"`
	Status       string          `json:"status"`
}

type sessionActivity struct {
	State          string    `json:"state"`
	LastActivityAt time.Time `json:"lastActivityAt"`
}

type sessionListResponse struct {
	Sessions []sessionDTO `json:"sessions"`
}

type sessionResponse struct {
	Session sessionDTO `json:"session"`
}

type killSessionResponse struct {
	SessionID string `json:"sessionId"`
	Freed     bool   `json:"freed"`
}

type restoreSessionResponse struct {
	SessionID string     `json:"sessionId"`
	Session   sessionDTO `json:"session"`
}

type renameSessionResponse struct {
	SessionID   string `json:"sessionId"`
	DisplayName string `json:"displayName"`
}

type cleanupSkippedSession struct {
	SessionID string `json:"sessionId"`
	Reason    string `json:"reason"`
}

type cleanupSessionsResponse struct {
	Cleaned []string                `json:"cleaned"`
	Skipped []cleanupSkippedSession `json:"skipped"`
}

type claimPRRequest struct {
	PR            string `json:"pr"`
	AllowTakeover bool   `json:"allowTakeover"`
}

type sessionPRDTO struct {
	URL            string    `json:"url"`
	Number         int       `json:"number"`
	State          string    `json:"state"`
	CI             string    `json:"ci"`
	Review         string    `json:"review"`
	Mergeability   string    `json:"mergeability"`
	ReviewComments bool      `json:"reviewComments"`
	UpdatedAt      time.Time `json:"updatedAt"`
}

type claimPRResponse struct {
	OK            bool           `json:"ok"`
	SessionID     string         `json:"sessionId"`
	PRs           []sessionPRDTO `json:"prs"`
	BranchChanged bool           `json:"branchChanged"`
	TakenOverFrom []string       `json:"takenOverFrom"`
}

type sessionListEntry struct {
	ID             string     `json:"id"`
	ProjectID      string     `json:"projectId"`
	Role           string     `json:"role"`
	Status         string     `json:"status,omitempty"`
	IssueID        string     `json:"issueId,omitempty"`
	Harness        string     `json:"harness,omitempty"`
	IsTerminated   bool       `json:"isTerminated"`
	LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
	CreatedAt      time.Time  `json:"createdAt"`
	UpdatedAt      time.Time  `json:"updatedAt"`
}

type sessionListOutput struct {
	Data []sessionListEntry `json:"data"`
	Meta struct {
		HiddenTerminatedCount int `json:"hiddenTerminatedCount"`
	} `json:"meta"`
}

func newSessionCommand(ctx *commandContext) *cobra.Command {
	cmd := &cobra.Command{
		Use:   "session",
		Short: "Manage agent sessions",
	}
	cmd.AddCommand(newSessionListCommand(ctx))
	cmd.AddCommand(newSessionGetCommand(ctx))
	cmd.AddCommand(newSessionKillCommand(ctx))
	cmd.AddCommand(newSessionRestoreCommand(ctx))
	cmd.AddCommand(newSessionRenameCommand(ctx))
	cmd.AddCommand(newSessionCleanupCommand(ctx))
	cmd.AddCommand(newSessionClaimPRCommand(ctx))
	return cmd
}

func newSessionListCommand(ctx *commandContext) *cobra.Command {
	var opts sessionListOptions
	cmd := &cobra.Command{
		Use:   "ls",
		Short: "List sessions",
		Args:  noArgs,
		RunE: func(cmd *cobra.Command, _ []string) error {
			return ctx.listSessions(cmd.Context(), cmd, opts)
		},
	}
	f := cmd.Flags()
	addSessionProjectFlag(f, &opts.project, "Filter by project ID")
	f.BoolVarP(&opts.all, "all", "a", false, "Include orchestrator sessions")
	f.BoolVar(&opts.includeTerminated, "include-terminated", false, "Include terminated sessions")
	f.BoolVar(&opts.json, "json", false, "Output as JSON")
	return cmd
}

func newSessionGetCommand(ctx *commandContext) *cobra.Command {
	var opts sessionOptions
	cmd := &cobra.Command{
		Use:   "get <id>",
		Short: "Fetch one session",
		Args:  oneSessionIDArg,
		RunE: func(cmd *cobra.Command, args []string) error {
			id, err := normalizeSessionID(args[0])
			if err != nil {
				return err
			}
			return ctx.getSession(cmd.Context(), cmd, id, opts)
		},
	}
	f := cmd.Flags()
	addSessionProjectFlag(f, &opts.project, "Project id to scope the lookup")
	f.BoolVar(&opts.json, "json", false, "Output as JSON")
	return cmd
}

func newSessionKillCommand(ctx *commandContext) *cobra.Command {
	var opts sessionOptions
	cmd := &cobra.Command{
		Use:   "kill <id>",
		Short: "Terminate a session",
		Args:  oneSessionIDArg,
		RunE: func(cmd *cobra.Command, args []string) error {
			id, err := normalizeSessionID(args[0])
			if err != nil {
				return err
			}
			return ctx.killSession(cmd.Context(), cmd, id, opts)
		},
	}
	addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
	return cmd
}

func newSessionRestoreCommand(ctx *commandContext) *cobra.Command {
	var opts sessionOptions
	cmd := &cobra.Command{
		Use:   "restore <id>",
		Short: "Relaunch a terminated session",
		Args:  oneSessionIDArg,
		RunE: func(cmd *cobra.Command, args []string) error {
			id, err := normalizeSessionID(args[0])
			if err != nil {
				return err
			}
			return ctx.restoreSession(cmd.Context(), cmd, id, opts)
		},
	}
	addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
	return cmd
}

func newSessionRenameCommand(ctx *commandContext) *cobra.Command {
	var opts sessionOptions
	cmd := &cobra.Command{
		Use:   "rename <id> <name>",
		Short: "Rename a session",
		Args:  sessionRenameArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			id, err := normalizeSessionID(args[0])
			if err != nil {
				return err
			}
			return ctx.renameSession(cmd.Context(), cmd, id, args[1], opts)
		},
	}
	addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
	return cmd
}

func newSessionCleanupCommand(ctx *commandContext) *cobra.Command {
	var opts sessionCleanupOptions
	cmd := &cobra.Command{
		Use:   "cleanup",
		Short: "Clean up terminated sessions",
		Long:  "Clean up terminated sessions by reclaiming eligible workspaces. Dirty worktrees are skipped by the daemon.",
		Args:  noArgs,
		RunE: func(cmd *cobra.Command, _ []string) error {
			return ctx.cleanupSessions(cmd.Context(), cmd, opts)
		},
	}
	addSessionProjectFlag(cmd.Flags(), &opts.project, "Filter by project ID")
	cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip confirmation prompt")
	return cmd
}

func newSessionClaimPRCommand(ctx *commandContext) *cobra.Command {
	var opts sessionClaimPROptions
	cmd := &cobra.Command{
		Use:   "claim-pr <session-id> <pr-ref>",
		Short: "Attach an existing PR to a session",
		Args: func(cmd *cobra.Command, args []string) error {
			if err := cobra.ExactArgs(2)(cmd, args); err != nil {
				return usageError{err}
			}
			if _, err := normalizeSessionID(args[0]); err != nil {
				return err
			}
			return nil
		},
		RunE: func(cmd *cobra.Command, args []string) error {
			id, err := normalizeSessionID(args[0])
			if err != nil {
				return err
			}
			return ctx.claimSessionPR(cmd.Context(), cmd, id, args[1], opts)
		},
	}
	addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
	cmd.Flags().BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the PR")
	cmd.Flags().BoolVar(&opts.json, "json", false, "Output as JSON")
	return cmd
}

func addSessionProjectFlag(flags interface {
	StringVarP(*string, string, string, string, string)
}, target *string, usage string) {
	flags.StringVarP(target, "project", "p", "", usage)
}

func oneSessionIDArg(cmd *cobra.Command, args []string) error {
	if err := cobra.ExactArgs(1)(cmd, args); err != nil {
		return usageError{err}
	}
	if _, err := normalizeSessionID(args[0]); err != nil {
		return err
	}
	return nil
}

func sessionRenameArgs(cmd *cobra.Command, args []string) error {
	if err := cobra.ExactArgs(2)(cmd, args); err != nil {
		return usageError{err}
	}
	if _, err := normalizeSessionID(args[0]); err != nil {
		return err
	}
	if strings.TrimSpace(args[1]) == "" {
		return usageError{errors.New("session name is required")}
	}
	return nil
}

func (c *commandContext) claimSessionPR(ctx context.Context, cmd *cobra.Command, id, ref string, opts sessionClaimPROptions) error {
	sess, err := c.fetchScopedSession(ctx, id, opts.project)
	if err != nil {
		return err
	}
	project, err := c.fetchProjectDetails(ctx, sess.ProjectID)
	if err != nil {
		return err
	}
	resolvedRef, err := c.resolvePRRef(ctx, ref, project)
	if err != nil {
		return err
	}
	var res claimPRResponse
	req := claimPRRequest{PR: resolvedRef, AllowTakeover: !opts.noTakeover}
	if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/pr/claim", req, &res); err != nil {
		return err
	}
	if opts.json {
		return writeJSON(cmd.OutOrStdout(), res)
	}
	return writeClaimPRResult(cmd, res)
}

func (c *commandContext) fetchProjectDetails(ctx context.Context, id string) (projectDetails, error) {
	var res projectGetResult
	if err := c.getJSON(ctx, "projects/"+url.PathEscape(id), &res); err != nil {
		return projectDetails{}, err
	}
	return res.Project, nil
}

func writeClaimPRResult(cmd *cobra.Command, res claimPRResponse) error {
	out := cmd.OutOrStdout()
	if len(res.PRs) == 0 {
		_, err := fmt.Fprintf(out, "session %s claimed PR\n", res.SessionID)
		return err
	}
	pr := res.PRs[0]
	if _, err := fmt.Fprintf(out, "session %s claimed PR #%d\n", res.SessionID, pr.Number); err != nil {
		return err
	}
	if _, err := fmt.Fprintf(out, "  pr:       %s\n", pr.URL); err != nil {
		return err
	}
	checkout := "already on PR branch"
	if res.BranchChanged {
		checkout = "switched to PR branch"
	}
	if _, err := fmt.Fprintf(out, "  checkout: %s\n", checkout); err != nil {
		return err
	}
	for _, owner := range res.TakenOverFrom {
		if _, err := fmt.Fprintf(out, "  taking over from %s\n", owner); err != nil {
			return err
		}
	}
	return nil
}

func (c *commandContext) listSessions(ctx context.Context, cmd *cobra.Command, opts sessionListOptions) error {
	params := url.Values{}
	if opts.project != "" {
		params.Set("project", opts.project)
	}
	if !opts.includeTerminated {
		params.Set("active", "true")
	}
	var res sessionListResponse
	if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil {
		return err
	}
	sessions := filterAndSortSessions(res.Sessions, opts.all)
	hiddenTerminatedCount := 0
	if !opts.includeTerminated {
		count, err := c.countHiddenTerminated(ctx, opts.project, opts.all)
		if err != nil {
			return err
		}
		hiddenTerminatedCount = count
	}
	if opts.json {
		out := sessionListOutput{Data: sessionListEntries(sessions)}
		out.Meta.HiddenTerminatedCount = hiddenTerminatedCount
		return writeJSON(cmd.OutOrStdout(), out)
	}
	return writeSessionList(cmd, sessions, hiddenTerminatedCount)
}

func (c *commandContext) countHiddenTerminated(ctx context.Context, project string, includeOrchestrators bool) (int, error) {
	params := url.Values{}
	if project != "" {
		params.Set("project", project)
	}
	params.Set("active", "false")
	var res sessionListResponse
	if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil {
		return 0, err
	}
	return len(filterAndSortSessions(res.Sessions, includeOrchestrators)), nil
}

func (c *commandContext) getSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error {
	sess, err := c.fetchScopedSession(ctx, id, opts.project)
	if err != nil {
		return err
	}
	if opts.json {
		return writeJSON(cmd.OutOrStdout(), sessionResponse{Session: sess})
	}
	return writeSessionDetails(cmd, sess)
}

func (c *commandContext) killSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error {
	if opts.project != "" {
		if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil {
			return err
		}
	}
	var res killSessionResponse
	if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/kill", struct{}{}, &res); err != nil {
		return err
	}
	if res.Freed {
		_, err := fmt.Fprintf(cmd.OutOrStdout(), "session %s killed\n", res.SessionID)
		return err
	}
	// freed=false: the workspace was preserved (e.g. uncommitted changes) — the
	// session is terminated either way, but the worktree is left for inspection.
	_, err := fmt.Fprintf(cmd.OutOrStdout(), "session %s killed (workspace preserved)\n", res.SessionID)
	return err
}

func (c *commandContext) restoreSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error {
	if opts.project != "" {
		if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil {
			return err
		}
	}
	var res restoreSessionResponse
	if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/restore", struct{}{}, &res); err != nil {
		return err
	}
	out := cmd.OutOrStdout()
	if _, err := fmt.Fprintf(out, "session %s restored\n", res.SessionID); err != nil {
		return err
	}
	if res.Session.ProjectID != "" {
		if _, err := fmt.Fprintf(out, "  project: %s\n", res.Session.ProjectID); err != nil {
			return err
		}
	}
	return nil
}

func (c *commandContext) renameSession(ctx context.Context, cmd *cobra.Command, id, displayName string, opts sessionOptions) error {
	if opts.project != "" {
		if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil {
			return err
		}
	}
	name := strings.TrimSpace(displayName)
	var res renameSessionResponse
	if err := c.patchJSON(ctx, "sessions/"+url.PathEscape(id), sessionRenameRequest{DisplayName: name}, &res); err != nil {
		return err
	}
	sessionID := res.SessionID
	if sessionID == "" {
		sessionID = id
	}
	if res.DisplayName != "" {
		name = res.DisplayName
	}
	_, err := fmt.Fprintf(cmd.OutOrStdout(), "session %s renamed to %q\n", sessionID, name)
	return err
}

func (c *commandContext) cleanupSessions(ctx context.Context, cmd *cobra.Command, opts sessionCleanupOptions) error {
	candidates, err := c.previewCleanupSessions(ctx, opts.project)
	if err != nil {
		return err
	}
	out := cmd.OutOrStdout()
	if _, err := fmt.Fprintln(out, "Checking for completed sessions..."); err != nil {
		return err
	}
	if _, err := fmt.Fprintln(out); err != nil {
		return err
	}
	if len(candidates) == 0 {
		_, err := fmt.Fprintln(out, "  No sessions to clean up.")
		return err
	}
	labels := cleanupLabels(candidates, opts.project)
	for _, label := range labels {
		if _, err := fmt.Fprintf(out, "  Would clean %s\n", label); err != nil {
			return err
		}
	}
	if !opts.yes {
		confirmed, err := confirmSessionCleanup(cmd, len(candidates), opts.project)
		if err != nil {
			return err
		}
		if !confirmed {
			_, err := fmt.Fprintln(out, "aborted")
			return err
		}
	}
	params := url.Values{}
	if opts.project != "" {
		params.Set("project", opts.project)
	}
	var res cleanupSessionsResponse
	if err := c.postJSON(ctx, apiPath("sessions/cleanup", params), struct{}{}, &res); err != nil {
		return err
	}
	cleaned := res.Cleaned
	labelByID := cleanupLabelByID(candidates, opts.project)
	for _, id := range cleaned {
		label := id
		if mapped := labelByID[id]; mapped != "" {
			label = mapped
		}
		if _, err := fmt.Fprintf(out, "  Cleaned: %s\n", label); err != nil {
			return err
		}
	}
	for _, skip := range res.Skipped {
		label := skip.SessionID
		if mapped := labelByID[skip.SessionID]; mapped != "" {
			label = mapped
		}
		if _, err := fmt.Fprintf(out, "  Skipped: %s (%s)\n", label, skip.Reason); err != nil {
			return err
		}
	}
	summary := fmt.Sprintf("\nCleanup complete. %d session%s cleaned", len(cleaned), pluralS(len(cleaned)))
	if len(res.Skipped) > 0 {
		summary += fmt.Sprintf(", %d skipped", len(res.Skipped))
	}
	_, err = fmt.Fprintf(out, "%s.\n", summary)
	return err
}

func (c *commandContext) previewCleanupSessions(ctx context.Context, project string) ([]sessionDTO, error) {
	params := url.Values{}
	params.Set("active", "false")
	if project != "" {
		params.Set("project", project)
	}
	var res sessionListResponse
	if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil {
		return nil, err
	}
	return filterAndSortSessions(res.Sessions, true), nil
}

func (c *commandContext) fetchScopedSession(ctx context.Context, id, project string) (sessionDTO, error) {
	var res sessionResponse
	if err := c.getJSON(ctx, "sessions/"+url.PathEscape(id), &res); err != nil {
		return sessionDTO{}, err
	}
	if project != "" && res.Session.ProjectID != project {
		return sessionDTO{}, usageError{fmt.Errorf("session %s is not in project %s", id, project)}
	}
	return res.Session, nil
}

func filterAndSortSessions(sessions []sessionDTO, includeOrchestrators bool) []sessionDTO {
	out := make([]sessionDTO, 0, len(sessions))
	for _, sess := range sessions {
		if !includeOrchestrators && sess.Kind == "orchestrator" {
			continue
		}
		out = append(out, sess)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].ProjectID != out[j].ProjectID {
			return out[i].ProjectID < out[j].ProjectID
		}
		return out[i].ID < out[j].ID
	})
	return out
}

func sessionListEntries(sessions []sessionDTO) []sessionListEntry {
	entries := make([]sessionListEntry, 0, len(sessions))
	for _, sess := range sessions {
		var last *time.Time
		if !sess.Activity.LastActivityAt.IsZero() {
			activity := sess.Activity.LastActivityAt
			last = &activity
		}
		entries = append(entries, sessionListEntry{
			ID:             sess.ID,
			ProjectID:      sess.ProjectID,
			Role:           sessionRole(sess),
			Status:         sess.Status,
			IssueID:        sess.IssueID,
			Harness:        sess.Harness,
			IsTerminated:   sess.IsTerminated,
			LastActivityAt: last,
			CreatedAt:      sess.CreatedAt,
			UpdatedAt:      sess.UpdatedAt,
		})
	}
	return entries
}

func cleanupLabels(sessions []sessionDTO, scopedProject string) []string {
	labels := make([]string, 0, len(sessions))
	for _, sess := range sessions {
		labels = append(labels, cleanupLabel(sess, scopedProject))
	}
	return labels
}

func cleanupLabelByID(sessions []sessionDTO, scopedProject string) map[string]string {
	labels := make(map[string]string, len(sessions))
	for _, sess := range sessions {
		labels[sess.ID] = cleanupLabel(sess, scopedProject)
	}
	return labels
}

func cleanupLabel(sess sessionDTO, scopedProject string) string {
	if scopedProject == "" && sess.ProjectID != "" {
		return sess.ProjectID + ":" + sess.ID
	}
	return sess.ID
}

func writeSessionList(cmd *cobra.Command, sessions []sessionDTO, hiddenTerminatedCount int) error {
	out := cmd.OutOrStdout()
	if len(sessions) == 0 {
		if _, err := fmt.Fprintln(out, "(no active sessions)"); err != nil {
			return err
		}
	} else {
		currentProject := ""
		for _, sess := range sessions {
			if sess.ProjectID != currentProject {
				if currentProject != "" {
					if _, err := fmt.Fprintln(out); err != nil {
						return err
					}
				}
				currentProject = sess.ProjectID
				if _, err := fmt.Fprintf(out, "%s:\n", currentProject); err != nil {
					return err
				}
			}
			if _, err := fmt.Fprintf(out, "  %s", sess.ID); err != nil {
				return err
			}
			parts := sessionLineParts(sess)
			if len(parts) > 0 {
				if _, err := fmt.Fprintf(out, "  %s", strings.Join(parts, "  ")); err != nil {
					return err
				}
			}
			if _, err := fmt.Fprintln(out); err != nil {
				return err
			}
		}
	}
	if hiddenTerminatedCount > 0 {
		_, err := fmt.Fprintf(out, "%d terminated session%s hidden. Use --include-terminated to show.\n", hiddenTerminatedCount, pluralS(hiddenTerminatedCount))
		return err
	}
	return nil
}

func sessionLineParts(sess sessionDTO) []string {
	parts := []string{}
	if !sess.Activity.LastActivityAt.IsZero() {
		parts = append(parts, "("+formatSessionAge(time.Since(sess.Activity.LastActivityAt))+")")
	}
	if sess.Status != "" {
		parts = append(parts, "["+sess.Status+"]")
	}
	if sess.Kind != "" {
		parts = append(parts, sess.Kind)
	}
	if sess.IssueID != "" {
		parts = append(parts, sess.IssueID)
	}
	return parts
}

func writeSessionDetails(cmd *cobra.Command, sess sessionDTO) error {
	out := cmd.OutOrStdout()
	fields := [][2]string{
		{"id", sess.ID},
		{"project", sess.ProjectID},
		{"name", sess.DisplayName},
		{"role", sessionRole(sess)},
		{"status", sess.Status},
		{"activity", sess.Activity.State},
		{"harness", sess.Harness},
		{"issue", sess.IssueID},
		{"terminated", fmt.Sprintf("%t", sess.IsTerminated)},
	}
	for _, field := range fields {
		if field[1] == "" {
			continue
		}
		if _, err := fmt.Fprintf(out, "%s: %s\n", field[0], field[1]); err != nil {
			return err
		}
	}
	if !sess.CreatedAt.IsZero() {
		if _, err := fmt.Fprintf(out, "created: %s\n", sess.CreatedAt.Format(time.RFC3339)); err != nil {
			return err
		}
	}
	if !sess.UpdatedAt.IsZero() {
		if _, err := fmt.Fprintf(out, "updated: %s\n", sess.UpdatedAt.Format(time.RFC3339)); err != nil {
			return err
		}
	}
	return nil
}

func sessionRole(sess sessionDTO) string {
	if sess.Kind == "orchestrator" {
		return "orchestrator"
	}
	return "worker"
}

func formatSessionAge(d time.Duration) string {
	if d < 0 {
		d = 0
	}
	if d < time.Minute {
		return fmt.Sprintf("%ds", int(d.Seconds()))
	}
	if d < time.Hour {
		return fmt.Sprintf("%dm", int(d.Minutes()))
	}
	if d < 24*time.Hour {
		return fmt.Sprintf("%dh", int(d.Hours()))
	}
	return fmt.Sprintf("%dd", int(d.Hours()/24))
}

func pluralS(n int) string {
	if n == 1 {
		return ""
	}
	return "s"
}

func apiPath(path string, params url.Values) string {
	if len(params) == 0 {
		return path
	}
	return path + "?" + params.Encode()
}

func normalizeSessionID(id string) (string, error) {
	trimmed := strings.TrimSpace(id)
	if trimmed == "" {
		return "", usageError{errors.New("session id is required")}
	}
	return trimmed, nil
}

func confirmSessionCleanup(cmd *cobra.Command, count int, project string) (bool, error) {
	scope := " across all projects"
	if project != "" {
		scope = fmt.Sprintf(" in project %q", project)
	}
	if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Clean %d terminated session%s%s? Type yes to confirm: ", count, pluralS(count), scope); err != nil {
		return false, err
	}
	reader := bufio.NewReader(cmd.InOrStdin())
	line, err := reader.ReadString('\n')
	if err != nil && line == "" {
		return false, err
	}
	return strings.EqualFold(strings.TrimSpace(line), "yes"), nil
}
