/*
 * ChatCLI - scheduler_command.go
 *
 * Top-level command handlers for /schedule, /wait, /jobs and the agent
 * tool adapters. Routes to either the in-process scheduler or the
 * remote daemon, transparently.
 */
package cli

import (
	"context"
	"fmt"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/diillson/chatcli/cli/scheduler"
	"github.com/diillson/chatcli/i18n"
)

// handleScheduleCommand implements /schedule …
func (cli *ChatCLI) handleScheduleCommand(ctx context.Context, input string) {
	if !cli.schedulerEnabled() {
		fmt.Println(colorize("  "+i18n.T("scheduler.disabled"), ColorYellow))
		return
	}
	args, err := tokenizeSchedulerInput(strings.TrimPrefix(input, "/schedule"))
	if err != nil {
		fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
		return
	}
	if len(args) == 0 {
		cli.printScheduleUsage()
		return
	}
	if args[0] == "help" {
		cli.printScheduleUsage()
		return
	}
	in, err := parseScheduleArgs(args)
	if err != nil {
		fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
		cli.printScheduleUsage()
		return
	}
	owner := cli.currentSchedulerOwner()
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	if cli.schedulerRemote != nil {
		out, err := cli.schedulerRemote.Enqueue(ctx, owner, in)
		if err != nil {
			fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
			return
		}
		if !out.OK {
			fmt.Println(colorize("  ❌ "+out.Error, ColorRed))
			return
		}
		fmt.Println(colorize("  ✔ "+i18n.T("scheduler.created", out.JobID, in.Name), ColorGreen))
		return
	}

	adapter := scheduler.NewToolAdapter(cli.scheduler)
	raw := mustJSON(in)
	res, _ := adapter.ScheduleJob(ctx, owner, raw)
	var out scheduler.ToolOutput
	_ = jsonDecode(res, &out)
	if !out.OK {
		fmt.Println(colorize("  ❌ "+out.Error, ColorRed))
		return
	}
	fmt.Println(colorize("  ✔ "+i18n.T("scheduler.created", out.JobID, in.Name), ColorGreen))
}

// handleWaitCommand implements /wait …
func (cli *ChatCLI) handleWaitCommand(ctx context.Context, input string) {
	if !cli.schedulerEnabled() {
		fmt.Println(colorize("  "+i18n.T("scheduler.disabled"), ColorYellow))
		return
	}
	args, err := tokenizeSchedulerInput(strings.TrimPrefix(input, "/wait"))
	if err != nil {
		fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
		return
	}
	if len(args) == 0 || args[0] == "help" {
		cli.printWaitUsage()
		return
	}
	in, err := parseWaitArgs(args)
	if err != nil {
		fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
		cli.printWaitUsage()
		return
	}
	owner := cli.currentSchedulerOwner()
	// Wait blocks by default; use --async to fire-and-forget.
	ctx, cancel := context.WithTimeout(ctx, 45*time.Minute)
	defer cancel()
	if cli.schedulerRemote != nil {
		out, err := cli.schedulerRemote.Enqueue(ctx, owner, in)
		if err != nil {
			fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
			return
		}
		if !out.OK {
			fmt.Println(colorize("  ❌ "+out.Error, ColorRed))
			return
		}
		if in.Async {
			fmt.Println(colorize("  ✔ "+i18n.T("scheduler.wait_async", out.JobID), ColorGreen))
			return
		}
		// Poll for terminal status.
		waitForTerminal(ctx, cli.schedulerRemote, out.JobID)
		return
	}
	adapter := scheduler.NewToolAdapter(cli.scheduler)
	raw := mustJSON(in)
	res, _ := adapter.WaitUntil(ctx, owner, raw)
	var out scheduler.ToolOutput
	_ = jsonDecode(res, &out)
	if !out.OK {
		fmt.Println(colorize("  ❌ "+out.Error, ColorRed))
		return
	}
	if in.Async {
		fmt.Println(colorize("  ✔ "+i18n.T("scheduler.wait_async", out.JobID), ColorGreen))
		return
	}
	fmt.Println(colorize(fmt.Sprintf("  ✔ %s", i18n.T("scheduler.wait_completed", out.JobID, string(out.Outcome))), ColorGreen))
	if out.Details != "" {
		fmt.Println(colorize(out.Details, ColorGray))
	}
}

// handleJobsCommand implements /jobs …
func (cli *ChatCLI) handleJobsCommand(ctx context.Context, input string) {
	if !cli.schedulerEnabled() {
		fmt.Println(colorize("  "+i18n.T("scheduler.disabled"), ColorYellow))
		return
	}
	args, err := tokenizeSchedulerInput(strings.TrimPrefix(input, "/jobs"))
	if err != nil {
		fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
		return
	}
	if len(args) == 0 {
		args = []string{"list"}
	}
	switch args[0] {
	case "help":
		cli.printJobsUsage()
	case "list":
		cli.jobsList(ctx, args[1:])
	case "show":
		cli.jobsShow(ctx, args[1:])
	case "tree":
		cli.jobsTree(ctx, args[1:])
	case "cancel":
		cli.jobsCancel(ctx, args[1:])
	case "pause":
		cli.jobsPause(ctx, args[1:])
	case "resume":
		cli.jobsResume(ctx, args[1:])
	case "logs":
		cli.jobsLogs(ctx, args[1:])
	case "history":
		cli.jobsList(ctx, []string{"--all"})
	case "daemon":
		cli.jobsDaemon(ctx, args[1:])
	case "gc":
		cli.jobsGC(ctx)
	case "clear", "clean", "prune":
		cli.jobsClear(ctx, args[1:])
	default:
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.unknown", args[0]), ColorYellow))
		cli.printJobsUsage()
	}
}

// ─── /jobs subcommands ────────────────────────────────────────

func (cli *ChatCLI) jobsList(ctx context.Context, args []string) {
	filter := scheduler.ListFilter{}
	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--all":
			filter.IncludeTerminal = true
		case "--status":
			if i+1 < len(args) {
				filter.Statuses = append(filter.Statuses, scheduler.JobStatus(args[i+1]))
				i++
			}
		case "--owner":
			if i+1 < len(args) {
				owner := scheduler.Owner{Kind: scheduler.OwnerKind(args[i+1])}
				if owner.Kind == "me" {
					owner = cli.currentSchedulerOwner()
				}
				filter.Owner = &owner
				i++
			}
		case "--tag":
			if i+1 < len(args) {
				filter.Tag = args[i+1]
				i++
			}
		case "--name":
			if i+1 < len(args) {
				filter.NameSubstr = args[i+1]
				i++
			}
		}
	}
	summaries := cli.schedulerList(ctx, filter)
	fmt.Println(scheduler.RenderList(summaries))
}

func (cli *ChatCLI) jobsShow(ctx context.Context, args []string) {
	if len(args) == 0 {
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.show_usage"), ColorYellow))
		return
	}
	id := scheduler.JobID(args[0])
	j := cli.schedulerQuery(ctx, id)
	if j == nil {
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.not_found", id), ColorYellow))
		return
	}
	fmt.Println(scheduler.RenderShow(j))
}

func (cli *ChatCLI) jobsTree(ctx context.Context, _ []string) {
	summaries := cli.schedulerList(ctx, scheduler.ListFilter{IncludeTerminal: true})
	jobs := make([]*scheduler.Job, 0, len(summaries))
	for _, s := range summaries {
		if j := cli.schedulerQuery(ctx, s.ID); j != nil {
			jobs = append(jobs, j)
		}
	}
	out := scheduler.RenderTree(jobs)
	if out == "" {
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.empty"), ColorGray))
		return
	}
	fmt.Println(out)
}

func (cli *ChatCLI) jobsCancel(ctx context.Context, args []string) {
	if len(args) == 0 {
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.cancel_usage"), ColorYellow))
		return
	}
	id := scheduler.JobID(args[0])
	reason := "user-cancelled"
	if len(args) > 1 {
		reason = strings.Join(args[1:], " ")
	}
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	var err error
	if cli.schedulerRemote != nil {
		err = cli.schedulerRemote.Cancel(ctx, cli.currentSchedulerOwner(), id, reason)
	} else {
		err = cli.scheduler.Cancel(id, reason, cli.currentSchedulerOwner())
	}
	if err != nil {
		fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
		return
	}
	fmt.Println(colorize("  ✔ "+i18n.T("scheduler.jobs.cancelled", id), ColorGreen))
}

func (cli *ChatCLI) jobsPause(ctx context.Context, args []string) {
	if len(args) == 0 {
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.pause_usage"), ColorYellow))
		return
	}
	id := scheduler.JobID(args[0])
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	var err error
	if cli.schedulerRemote != nil {
		err = cli.schedulerRemote.Pause(ctx, cli.currentSchedulerOwner(), id, "user-paused")
	} else {
		err = cli.scheduler.Pause(id, "user-paused", cli.currentSchedulerOwner())
	}
	if err != nil {
		fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
		return
	}
	fmt.Println(colorize("  ✔ "+i18n.T("scheduler.jobs.paused", id), ColorGreen))
}

func (cli *ChatCLI) jobsResume(ctx context.Context, args []string) {
	if len(args) == 0 {
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.resume_usage"), ColorYellow))
		return
	}
	id := scheduler.JobID(args[0])
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	var err error
	if cli.schedulerRemote != nil {
		err = cli.schedulerRemote.Resume(ctx, cli.currentSchedulerOwner(), id)
	} else {
		err = cli.scheduler.Resume(id, cli.currentSchedulerOwner())
	}
	if err != nil {
		fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
		return
	}
	fmt.Println(colorize("  ✔ "+i18n.T("scheduler.jobs.resumed", id), ColorGreen))
}

func (cli *ChatCLI) jobsLogs(ctx context.Context, args []string) {
	if len(args) == 0 {
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.logs_usage"), ColorYellow))
		return
	}
	id := scheduler.JobID(args[0])
	j := cli.schedulerQuery(ctx, id)
	if j == nil {
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.not_found", id), ColorYellow))
		return
	}
	if len(j.History) == 0 {
		fmt.Println(colorize("  "+i18n.T("scheduler.jobs.logs_empty"), ColorGray))
		return
	}
	// Render history with a cycle separator so recurring jobs don't
	// look like one big blur. Cycle 0 = non-recurring (no separator).
	// Within a cycle, attempts (retries) print indented under the
	// cycle header so the relation is obvious.
	prevCycle := -1
	for _, h := range j.History {
		if h.CycleNum > 0 && h.CycleNum != prevCycle {
			fmt.Println(colorize("  "+i18n.T("scheduler.jobs.cycle_header", h.CycleNum), ColorCyan))
			prevCycle = h.CycleNum
		}
		header := fmt.Sprintf("  #%d %s %s (%s)", h.AttemptNum, h.StartedAt.Format(time.RFC3339), h.Outcome, h.Duration)
		if h.CycleNum > 0 {
			// Indent attempts under their cycle header.
			header = "    #" + strconv.Itoa(h.AttemptNum) + " " + h.StartedAt.Format(time.RFC3339) + " " + string(h.Outcome) + " (" + h.Duration.String() + ")"
		}
		fmt.Println(header)
		pref := "      "
		if h.CycleNum == 0 {
			pref = "    "
		}
		if h.Output != "" {
			fmt.Println(colorize(indent(pref, h.Output), ColorGray))
		}
		if h.Error != "" {
			fmt.Println(colorize(indent(pref, i18n.T("scheduler.jobs.err_prefix")+h.Error), ColorRed))
		}
		if h.ConditionDetails != "" {
			fmt.Println(colorize(indent(pref, i18n.T("scheduler.jobs.cond_prefix")+h.ConditionDetails), ColorGray))
		}
	}
}

func (cli *ChatCLI) jobsDaemon(ctx context.Context, args []string) {
	sub := "status"
	if len(args) > 0 {
		sub = args[0]
	}
	switch sub {
	case "status":
		if cli.schedulerRemote != nil {
			ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
			defer cancel()
			stats, err := cli.schedulerRemote.Stats(ctx)
			if err != nil {
				fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
				return
			}
			fmt.Printf("  %s\n  uptime   : %s\n  jobs     : %d active, queue=%d\n  wal segs : %d\n  clients  : %d\n",
				i18n.T("scheduler.daemon.status_header"),
				stats.Uptime, stats.JobsActive, stats.QueueDepth, stats.WALSegments, stats.Connections)
			return
		}
		if cli.scheduler != nil {
			fmt.Println("  " + i18n.T("scheduler.daemon.local"))
			return
		}
		fmt.Println(colorize("  "+i18n.T("scheduler.daemon.none"), ColorGray))
	case "start", "stop", "restart":
		fmt.Println(colorize("  "+i18n.T("scheduler.daemon.use_subcommand"), ColorYellow))
	default:
		fmt.Println(colorize("  "+i18n.T("scheduler.daemon.unknown", sub), ColorYellow))
	}
}

// jobsClear removes terminal jobs from the in-memory store and the WAL
// so /jobs history stops growing forever. Default scope: every terminal
// status. Filter flags narrow the scope:
//
//	--failed        — only failed jobs
//	--succeeded     — only succeeded jobs
//	--cancelled     — only cancelled jobs
//	--timed-out     — only timed-out jobs
//	--status <S>    — repeatable; explicit status list
//	--older-than <D> — only jobs whose UpdatedAt is older than D
//	--name <substr> — only names containing substr (case-insensitive)
//	--mine          — only jobs owned by the current session
//	--yes / -y      — skip the confirmation prompt
//
// Active jobs (pending/running/waiting/paused) are NEVER pruned — use
// /jobs cancel <id> to stop them first.
func (cli *ChatCLI) jobsClear(_ context.Context, args []string) {
	if cli.scheduler == nil && cli.schedulerRemote == nil {
		fmt.Println(colorize("  "+i18n.T("scheduler.disabled"), ColorYellow))
		return
	}
	// Remote scheduler doesn't expose a Prune RPC yet — surface a
	// clear "not implemented" instead of silently doing nothing.
	if cli.schedulerRemote != nil {
		fmt.Println(colorize("  /jobs clear is not yet supported against a remote daemon — run on the local scheduler instead.", ColorYellow))
		return
	}

	var (
		statuses    []scheduler.JobStatus
		olderThan   time.Duration
		nameSubstr  string
		ownerFilter *scheduler.Owner
		skipConfirm bool
	)
	addStatus := func(s scheduler.JobStatus) {
		for _, existing := range statuses {
			if existing == s {
				return
			}
		}
		statuses = append(statuses, s)
	}
	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--failed":
			addStatus(scheduler.StatusFailed)
		case "--succeeded", "--success", "--completed":
			addStatus(scheduler.StatusCompleted)
		case "--cancelled", "--canceled":
			addStatus(scheduler.StatusCancelled)
		case "--timed-out", "--timedout":
			addStatus(scheduler.StatusTimedOut)
		case "--status":
			if i+1 < len(args) {
				addStatus(scheduler.JobStatus(args[i+1]))
				i++
			}
		case "--older-than":
			if i+1 < len(args) {
				if d, err := time.ParseDuration(args[i+1]); err == nil {
					olderThan = d
				} else {
					fmt.Println(colorize("  ❌ invalid --older-than value: "+args[i+1], ColorRed))
					return
				}
				i++
			}
		case "--name":
			if i+1 < len(args) {
				nameSubstr = args[i+1]
				i++
			}
		case "--mine":
			o := cli.currentSchedulerOwner()
			ownerFilter = &o
		case "--yes", "-y":
			skipConfirm = true
		default:
			fmt.Println(colorize("  ❌ unknown flag: "+args[i], ColorRed))
			return
		}
	}

	// Two-step destructive UX: first call shows what would be removed
	// and instructs the user to re-run with --yes. Second call (with
	// --yes) actually deletes. We do NOT prompt mid-executor for stdin
	// confirmation because go-prompt holds the terminal in raw mode
	// during executor execution — fmt.Scanln / bufio readers there
	// either lock up the terminal or silently swallow keystrokes
	// (reported by user 2026-04-24). The two-step pattern mirrors
	// kubectl/terraform, dodges the stdin race entirely, and stays
	// scriptable (the agent can schedule cleanup with --yes inline).
	previewFilter := scheduler.ListFilter{
		IncludeTerminal: true,
		Statuses:        statuses,
		Owner:           ownerFilter,
		NameSubstr:      nameSubstr,
	}
	preview := cli.scheduler.List(previewFilter)
	preview = filterTerminal(preview, olderThan)
	if len(preview) == 0 {
		fmt.Println(colorize("  (no terminal jobs match the filter — nothing to clear)", ColorGray))
		return
	}

	if !skipConfirm {
		desc := jobsClearDescription(statuses, olderThan, nameSubstr, ownerFilter)
		fmt.Println(colorize(fmt.Sprintf("  Would delete %d terminal job(s)%s:", len(preview), desc), ColorYellow))
		maxN := 10
		if len(preview) < maxN {
			maxN = len(preview)
		}
		for i := 0; i < maxN; i++ {
			s := preview[i]
			fmt.Printf("    %s  %s  %s\n",
				colorize(string(s.Status), ColorGray),
				colorize(string(s.ID), ColorGray),
				s.Name)
		}
		if len(preview) > maxN {
			fmt.Println(colorize(fmt.Sprintf("    … and %d more", len(preview)-maxN), ColorGray))
		}
		fmt.Println(colorize("  Re-run the same command with --yes (or -y) to actually delete.", ColorYellow))
		return
	}

	removed, err := cli.scheduler.Prune(scheduler.PruneFilter{
		Statuses:   statuses,
		Owner:      ownerFilter,
		OlderThan:  olderThan,
		NameSubstr: nameSubstr,
	})
	if err != nil {
		fmt.Println(colorize("  ❌ "+err.Error(), ColorRed))
		return
	}
	fmt.Println(colorize(fmt.Sprintf("  ✔ removed %d terminal job(s) from history", len(removed)), ColorGreen))
}

// filterTerminal narrows a List preview to terminal entries that also
// satisfy an OlderThan duration. List() already enforces Statuses /
// Owner / NameSubstr; this is the post-filter for OlderThan since
// PruneFilter handles it but ListFilter doesn't.
func filterTerminal(in []scheduler.JobSummary, olderThan time.Duration) []scheduler.JobSummary {
	out := make([]scheduler.JobSummary, 0, len(in))
	now := time.Now()
	for _, s := range in {
		if !s.Status.IsTerminal() {
			continue
		}
		if olderThan > 0 && now.Sub(s.UpdatedAt) < olderThan {
			continue
		}
		out = append(out, s)
	}
	return out
}

// jobsClearDescription renders a short human-readable filter summary
// for the confirmation prompt.
func jobsClearDescription(statuses []scheduler.JobStatus, olderThan time.Duration, nameSubstr string, owner *scheduler.Owner) string {
	parts := []string{}
	if len(statuses) == 0 {
		parts = append(parts, "all terminal statuses")
	} else {
		labels := make([]string, len(statuses))
		for i, s := range statuses {
			labels[i] = string(s)
		}
		parts = append(parts, "status="+strings.Join(labels, "|"))
	}
	if owner != nil {
		parts = append(parts, fmt.Sprintf("owner=%s:%s", owner.Kind, owner.ID))
	}
	if olderThan > 0 {
		parts = append(parts, "older-than="+olderThan.String())
	}
	if nameSubstr != "" {
		parts = append(parts, "name~="+nameSubstr)
	}
	return " (" + strings.Join(parts, ", ") + ")"
}

func (cli *ChatCLI) jobsGC(ctx context.Context) {
	if cli.schedulerRemote != nil {
		ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
		defer cancel()
		_ = cli.schedulerRemote.Snapshot(ctx)
	} else if cli.scheduler != nil {
		_ = cli.scheduler.Snapshot()
	}
	fmt.Println(colorize("  ✔ "+i18n.T("scheduler.jobs.gc_done"), ColorGreen))
}

// ─── Helpers ──────────────────────────────────────────────────

// Snapshot exposes writeSnapshotNow for the CLI command. Public method
// belongs on *Scheduler; see scheduler.go.
// (The exported wrapper is added for completeness.)

func (cli *ChatCLI) schedulerList(ctx context.Context, filter scheduler.ListFilter) []scheduler.JobSummary {
	if cli.schedulerRemote != nil {
		ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
		defer cancel()
		list, _ := cli.schedulerRemote.List(ctx, filter)
		return list
	}
	if cli.scheduler != nil {
		return cli.scheduler.List(filter)
	}
	return nil
}

func (cli *ChatCLI) schedulerQuery(ctx context.Context, id scheduler.JobID) *scheduler.Job {
	if cli.schedulerRemote != nil {
		ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
		defer cancel()
		j, err := cli.schedulerRemote.Query(ctx, id)
		if err != nil {
			return nil
		}
		return j
	}
	if cli.scheduler != nil {
		j, err := cli.scheduler.Query(id)
		if err != nil {
			return nil
		}
		return j
	}
	return nil
}

// waitForTerminal polls the remote client until the job reaches a
// terminal state or ctx is cancelled.
func waitForTerminal(ctx context.Context, c *scheduler.RemoteClient, id scheduler.JobID) {
	t := time.NewTicker(500 * time.Millisecond)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			fmt.Println(colorize("  ⚠ "+i18n.T("scheduler.wait_ctx_cancelled"), ColorYellow))
			return
		case <-t.C:
			j, err := c.Query(ctx, id)
			if err != nil {
				continue
			}
			if j.Status.IsTerminal() {
				fmt.Println(colorize(fmt.Sprintf("  ✔ job %s → %s", id, j.Status), ColorGreen))
				return
			}
		}
	}
}

// ─── Argument parsers ─────────────────────────────────────────

// parseScheduleArgs reads /schedule CLI flags into a ToolInput. The
// first positional argument is the job name.
func parseScheduleArgs(args []string) (scheduler.ToolInput, error) {
	in := scheduler.ToolInput{Tags: map[string]string{}}
	if len(args) == 0 {
		return in, fmt.Errorf("schedule: missing job name")
	}
	// If the first token is not a flag, take it as the name.
	if !strings.HasPrefix(args[0], "--") {
		in.Name = args[0]
		args = args[1:]
	}
	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--when":
			in.When, i = takeString(args, i)
		case "--cron":
			val, ni := takeString(args, i)
			in.When = "cron:" + val
			i = ni
		case "--every":
			val, ni := takeString(args, i)
			in.When = "every " + val
			i = ni
		case "--do":
			in.Do, i = takeString(args, i)
		case "--wait", "--until":
			in.Until, i = takeString(args, i)
		case "--timeout":
			in.Timeout, i = takeString(args, i)
		case "--wait-timeout":
			in.WaitTimeout, i = takeString(args, i)
		case "--poll":
			in.PollInterval, i = takeString(args, i)
		case "--max-polls":
			val, ni := takeString(args, i)
			if n, err := strconv.Atoi(val); err == nil {
				in.MaxPolls = n
			}
			i = ni
		case "--max-retries":
			val, ni := takeString(args, i)
			if n, err := strconv.Atoi(val); err == nil {
				in.MaxRetries = n
			}
			i = ni
		case "--depends-on":
			val, ni := takeString(args, i)
			in.DependsOn = append(in.DependsOn, val)
			i = ni
		case "--triggers":
			val, ni := takeString(args, i)
			in.Triggers = append(in.Triggers, val)
			i = ni
		case "--ttl":
			in.TTL, i = takeString(args, i)
		case "--description":
			in.Description, i = takeString(args, i)
		case "--tag":
			val, ni := takeString(args, i)
			parts := strings.SplitN(val, "=", 2)
			if len(parts) == 2 {
				in.Tags[parts[0]] = parts[1]
			} else {
				in.Tags[parts[0]] = ""
			}
			i = ni
		case "--on-timeout":
			in.OnTimeout, i = takeString(args, i)
		case "--i-know":
			// Pre-authorize ShellPolicyAsk commands at enqueue
			// preflight. Propagates to Job.DangerousConfirmed via
			// ToolInput.IKnow. Note: denylist rules are NOT
			// overridable — --i-know cannot override a Deny.
			in.IKnow = true
		case "--async":
			in.Async = true
		default:
			if strings.HasPrefix(args[i], "--") {
				return in, fmt.Errorf("schedule: unknown flag %s", args[i])
			}
			// Bare positional after the name is almost always a quoting
			// mistake (e.g. --do /run X Y instead of --do "/run X Y").
			// Surfacing the offending token lets the user fix it instead
			// of silently dropping it and getting a downstream parse
			// error like "action: cannot parse".
			return in, fmt.Errorf("schedule: unexpected positional %q (did you forget to quote a multi-word value?)", args[i])
		}
	}
	if in.When == "" {
		return in, fmt.Errorf("schedule: --when is required (got %q)", strings.Join(args, " "))
	}
	if in.Do == "" {
		return in, fmt.Errorf("schedule: --do is required")
	}
	return in, nil
}

func parseWaitArgs(args []string) (scheduler.ToolInput, error) {
	in := scheduler.ToolInput{Tags: map[string]string{}}
	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--until":
			in.Until, i = takeString(args, i)
		case "--then":
			in.Do, i = takeString(args, i)
		case "--every":
			in.PollInterval, i = takeString(args, i)
		case "--timeout":
			in.WaitTimeout, i = takeString(args, i)
		case "--max-polls":
			val, ni := takeString(args, i)
			if n, err := strconv.Atoi(val); err == nil {
				in.MaxPolls = n
			}
			i = ni
		case "--async":
			in.Async = true
		case "--on-timeout":
			in.OnTimeout, i = takeString(args, i)
		case "--name":
			in.Name, i = takeString(args, i)
		default:
			if strings.HasPrefix(args[i], "--") {
				return in, fmt.Errorf("wait: unknown flag %s", args[i])
			}
			return in, fmt.Errorf("wait: unexpected positional %q (did you forget to quote a multi-word value?)", args[i])
		}
	}
	if in.Until == "" {
		return in, fmt.Errorf("wait: --until is required")
	}
	if in.When == "" {
		in.When = "when-ready"
	}
	if in.Do == "" {
		in.Do = "noop"
	}
	if in.Name == "" {
		host, _ := os.Hostname()
		in.Name = fmt.Sprintf("wait-%s-%d", host, time.Now().UnixNano()%100000)
	}
	return in, nil
}

func takeString(args []string, i int) (string, int) {
	if i+1 >= len(args) {
		return "", i
	}
	return args[i+1], i + 1
}

// tokenizeSchedulerInput splits a /schedule, /wait, or /jobs argument
// string into tokens while respecting single- and double-quoted spans
// so that flag values like --do "/run X Y" or --tag 'k=v with space'
// arrive as a single token. Backslash escapes the next character inside
// a quoted span; outside quotes it is taken literally.
//
// strings.Fields cannot do this because it splits on every whitespace
// run, which truncates --do "/run X Y" to --do "/run and leaks the
// rest as bare positionals — silently dropping them and producing a
// confusing "action: cannot parse" error downstream.
//
// Returns an error only when a quote is left unterminated, so callers
// can surface a precise message to the user.
func tokenizeSchedulerInput(input string) ([]string, error) {
	var (
		out     []string
		buf     strings.Builder
		inQuote rune // 0 = none, '"' or '\''
		escaped bool
		hasTok  bool
	)
	flush := func() {
		if hasTok {
			out = append(out, buf.String())
			buf.Reset()
			hasTok = false
		}
	}
	for _, r := range input {
		if escaped {
			buf.WriteRune(r)
			hasTok = true
			escaped = false
			continue
		}
		if inQuote != 0 {
			if r == '\\' && inQuote == '"' {
				// Backslash only escapes within double quotes; inside
				// single quotes it is literal (POSIX-ish).
				escaped = true
				continue
			}
			if r == inQuote {
				inQuote = 0
				// An empty "" or '' still counts as a token.
				hasTok = true
				continue
			}
			buf.WriteRune(r)
			hasTok = true
			continue
		}
		switch r {
		case '"', '\'':
			inQuote = r
			hasTok = true
		case '\\':
			escaped = true
		case ' ', '\t', '\n', '\r':
			flush()
		default:
			buf.WriteRune(r)
			hasTok = true
		}
	}
	if inQuote != 0 {
		return nil, fmt.Errorf("unterminated %c-quoted argument", inQuote)
	}
	if escaped {
		return nil, fmt.Errorf("trailing backslash with nothing to escape")
	}
	flush()
	return out, nil
}

func indent(pref, s string) string {
	lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
	for k := range lines {
		lines[k] = pref + lines[k]
	}
	return strings.Join(lines, "\n")
}

// ─── Usage banners ────────────────────────────────────────────

func (cli *ChatCLI) printScheduleUsage() {
	fmt.Println(colorize(i18n.T("scheduler.schedule.usage_header"), ColorCyan+ColorBold))
	fmt.Println("  " + i18n.T("scheduler.schedule.usage_example_1"))
	fmt.Println("  " + i18n.T("scheduler.schedule.usage_example_2"))
	fmt.Println("  " + i18n.T("scheduler.schedule.usage_example_3"))
	fmt.Println("  " + i18n.T("scheduler.schedule.usage_flags"))
}

func (cli *ChatCLI) printWaitUsage() {
	fmt.Println(colorize(i18n.T("scheduler.wait.usage_header"), ColorCyan+ColorBold))
	fmt.Println("  " + i18n.T("scheduler.wait.usage_example_1"))
	fmt.Println("  " + i18n.T("scheduler.wait.usage_example_2"))
	fmt.Println("  " + i18n.T("scheduler.wait.usage_flags"))
}

func (cli *ChatCLI) printJobsUsage() {
	fmt.Println(colorize(i18n.T("scheduler.jobs.usage_header"), ColorCyan+ColorBold))
	fmt.Println("  /jobs list [--all | --status X | --owner Y | --tag k=v | --name N]")
	fmt.Println("  /jobs show <id>")
	fmt.Println("  /jobs tree")
	fmt.Println("  /jobs cancel <id> [reason...]")
	fmt.Println("  /jobs pause <id>")
	fmt.Println("  /jobs resume <id>")
	fmt.Println("  /jobs logs <id>")
	fmt.Println("  /jobs history")
	fmt.Println("  /jobs clear [--failed | --succeeded | --cancelled | --timed-out | --status S | --older-than D | --name N | --mine | --yes]")
	fmt.Println("              (aliases: clean, prune)")
	fmt.Println("  /jobs daemon {status}")
	fmt.Println("  /jobs gc")
}

// mustJSON / jsonDecode are tiny wrappers around encoding/json.
func mustJSON(v any) string {
	b, err := marshalJSON(v)
	if err != nil {
		return "{}"
	}
	return string(b)
}

// marshalJSON is the real marshaller; kept distinct so mustJSON above
// can be unit-tested without importing encoding/json directly here.
var marshalJSON = func(v any) ([]byte, error) {
	return marshalJSONImpl(v)
}

// jsonDecode mirrors marshalJSON for symmetry.
func jsonDecode(raw string, dst any) error {
	return jsonDecodeImpl(raw, dst)
}
