// This file implements the /goal command (mirrors pi-goal / Claude Code's goal
// mode): given a high-level objective, pigo runs the agent autonomously —
// re-prompting it turn after turn from the loop's follow-up seam — until the
// model declares the goal done (goal_complete), reports a true impasse
// (goal_blocked), or a safety guard (max turns / no-progress) or the token
// budget stops it.
//
// /goal is intercepted in the REPL loop rather than routed through a slash
// Action closure because it must run agent streams and mutate the shared
// context and goal state — none of which a pure string→string Action can do,
// exactly like /compact and /fork. It reaches the session's collaborators and
// mutable state through the cli.Host contract, so it need not import the
// concrete replDeps aggregate that assembles them.
//
// Scope: core autonomous continuation plus an optional token budget. Goal state
// lives only in the session's in-memory GoalState (host.Goal()); it is not
// persisted across process restarts.
package goal

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

	"github.com/smallnest/pigo/internal/agentcore"
	"github.com/smallnest/pigo/internal/agenttool"
	"github.com/smallnest/pigo/internal/cli"
	"github.com/smallnest/pigo/internal/cli/run"
	"github.com/smallnest/pigo/internal/cli/ui"
	"github.com/smallnest/pigo/internal/compaction"
	"github.com/smallnest/pigo/internal/hooks"
	"github.com/smallnest/pigo/internal/provider"
	"github.com/smallnest/pigo/internal/runtime"
	"github.com/smallnest/pigo/internal/trust"
)

// goalMaxAutomaticTurns caps how many autonomous continuations a single /goal
// run will issue before pausing, a runaway guard (mirrors pi-goal's automaticTurns).
const goalMaxAutomaticTurns = 25

// goalMaxNoProgress pauses the run after this many consecutive tool-free
// continuations, so a model looping without acting cannot spin forever (mirrors
// pi-goal's noProgressTurns).
const goalMaxNoProgress = 3

// runGoal parses and dispatches a /goal invocation. setCancel publishes the
// active run's cancel func so the REPL's SIGINT handler can interrupt an
// autonomous run (same plumbing as a normal turn).
func RunGoal(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, line string) {
	args := strings.TrimSpace(strings.TrimPrefix(line, "/goal"))

	switch {
	case args == "":
		printGoalStatus(out, host.Goal())
		return
	case args == "clear":
		host.Goal().Clear()
		fmt.Fprintln(out, "goal cleared")
		return
	case args == "pause":
		snap := host.Goal().Snapshot()
		if snap.Status != agenttool.GoalActive && snap.Status != agenttool.GoalPaused {
			fmt.Fprintln(out, "no active goal to pause")
			return
		}
		host.Goal().SetStatus(agenttool.GoalPaused)
		fmt.Fprintln(out, "goal paused — run /goal resume to continue")
		return
	case args == "resume":
		snap := host.Goal().Snapshot()
		if snap.Status != agenttool.GoalPaused && snap.Status != agenttool.GoalBudgetLimited {
			fmt.Fprintln(out, "no paused goal to resume")
			return
		}
		host.Goal().Resume()
		fmt.Fprintf(out, "resuming goal: %s\n", ui.OneLine(snap.Objective))
		runGoalLoop(setCancel, out, host)
		return
	}

	// Otherwise args is a new objective, optionally prefixed with --tokens N.
	objective, budget, err := parseGoalObjective(args)
	if err != nil {
		fmt.Fprintf(out, "pigo: %v\n", err)
		return
	}
	if strings.TrimSpace(objective) == "" {
		fmt.Fprintln(out, "usage: /goal [--tokens N] <objective>")
		return
	}
	host.Goal().Start(newGoalID(), objective, budget)
	if budget > 0 {
		fmt.Fprintf(out, "goal set (token budget %d): %s\n", budget, ui.OneLine(objective))
	} else {
		fmt.Fprintf(out, "goal set: %s\n", ui.OneLine(objective))
	}
	runGoalLoop(setCancel, out, host)
}

// newGoalID returns a short unique id for a goal (used to key goal_complete's
// exact-id contract in a future extension; here it just labels the goal).
func newGoalID() string { return "goal-" + strconv.FormatInt(time.Now().UnixNano(), 36) }

// parseGoalObjective splits an optional leading "--tokens N" flag from the
// objective text. N accepts a bare integer or a k/m suffix (100k, 1m). The flag
// must lead; anything after it (or the whole string when absent) is the
// objective.
func parseGoalObjective(args string) (objective string, budget int, err error) {
	rest := args
	if strings.HasPrefix(rest, "--tokens") {
		rest = strings.TrimSpace(strings.TrimPrefix(rest, "--tokens"))
		// The value is the next whitespace-delimited token.
		var valTok string
		if i := strings.IndexAny(rest, " \t"); i >= 0 {
			valTok = rest[:i]
			rest = strings.TrimSpace(rest[i+1:])
		} else {
			valTok = rest
			rest = ""
		}
		budget, err = parseTokenBudget(valTok)
		if err != nil {
			return "", 0, err
		}
	}
	return rest, budget, nil
}

// parseTokenBudget parses a token count with an optional k/m (×1000/×1000000)
// suffix, case-insensitive. It rejects a non-positive or malformed value.
func parseTokenBudget(s string) (int, error) {
	s = strings.TrimSpace(strings.ToLower(s))
	if s == "" {
		return 0, fmt.Errorf("--tokens requires a value (e.g. --tokens 100k)")
	}
	mult := 1
	switch {
	case strings.HasSuffix(s, "k"):
		mult = 1000
		s = strings.TrimSuffix(s, "k")
	case strings.HasSuffix(s, "m"):
		mult = 1000000
		s = strings.TrimSuffix(s, "m")
	}
	n, convErr := strconv.Atoi(s)
	if convErr != nil || n <= 0 {
		return 0, fmt.Errorf("invalid --tokens value %q (want a positive number, optionally with k/m)", s)
	}
	return n * mult, nil
}

// goalContinuationPrompt is the follow-up injected each autonomous turn to keep
// the model working toward the goal. The objective itself is re-stated every
// turn by the GoalReminderProvider, so this only nudges continuation.
const goalContinuationPrompt = "Continue working toward the goal. When every requirement is " +
	"verifiably met, call goal_complete with a summary. If you hit a true impasse you cannot work " +
	"around, call goal_blocked with concrete evidence. Otherwise keep going — do not stop or ask " +
	"the user whether to continue."

// goalFollowUpDecision decides, from a goal snapshot, whether the autonomous
// loop should issue another continuation turn. It is pure so the branch logic
// (complete/blocked terminal states, token budget, turn cap, no-progress guard)
// can be unit-tested without running a real agent stream. The returned status is
// the terminal status to record when cont is false (GoalIdle means "leave the
// status as-is" — used for the already-terminal complete/blocked cases).
func goalFollowUpDecision(snap agenttool.GoalSnapshot) (cont bool, terminal agenttool.GoalStatus) {
	switch snap.Status {
	case agenttool.GoalComplete, agenttool.GoalBlocked:
		// A goal tool already ended the run; nothing to continue.
		return false, agenttool.GoalIdle
	}
	if snap.TokenBudget > 0 && snap.TokensUsed >= snap.TokenBudget {
		return false, agenttool.GoalBudgetLimited
	}
	if snap.Iterations >= goalMaxAutomaticTurns {
		return false, agenttool.GoalPaused
	}
	if snap.NoProgress >= goalMaxNoProgress {
		return false, agenttool.GoalPaused
	}
	return true, agenttool.GoalIdle
}

// runGoalLoop drives the autonomous goal run. It assembles a run just like
// streamRun but with the goal tools (goal_complete/goal_blocked) added, the goal
// reminder wired alongside the todo reminder, and a GetFollowUpMessages hook that
// re-prompts the model each time the inner loop settles — until a goal tool ends
// the run or goalFollowUpDecision trips a guard. On return it prints the outcome
// and persists the turn. The run reuses the REPL's SIGINT cancel plumbing via
// setCancel.
func runGoalLoop(setCancel func(context.CancelFunc), out io.Writer, host cli.Host) {
	goalReg := goalToolRegistry(host.Registry(), host.Goal())
	reminders := goalReminders(host.Registry(), host.Goal())

	runCtx, cancel := context.WithCancel(context.Background())
	setCancel(cancel)
	defer func() {
		cancel()
		setCancel(nil)
	}()

	// lastSeen tracks how many messages we have already accounted for, so each
	// settle folds in only the assistant turns produced since the previous one:
	// their output tokens (budget) and whether any tool ran (no-progress guard).
	lastSeen := len(host.AgentCtx().Messages)

	cfg := runtime.RunConfig{
		LoopConfig: runtime.LoopConfig{
			Model:         host.Live().Model,
			Provider:      host.Live().ProviderName,
			ThinkingLevel: host.Live().ThinkingLevel,
			Stream:        provider.StreamFnFromProvider(host.Live().Provider),
			GetAPIKey:     host.Creds().GetAPIKey,
			ContextWindow: host.Live().ContextWindow,
			Compaction:    compaction.DefaultCompactionSettings,
		},
		Batch: agenttool.BatchConfig{
			ToolExecutorConfig: agenttool.ToolExecutorConfig{
				Registry:       goalReg,
				BeforeToolCall: trust.BeforeToolCall(host.Trust(), host.Cwd(), host.Input(), out, host.ConfirmMu()),
			},
		},
		Reminders: reminders,
		GetFollowUpMessages: func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage {
			// Account for the turns produced since the last settle. Auto-compaction
			// can shrink agentCtx.Messages in place (summary + tail) between settles,
			// dropping its length below lastSeen; clamp so the slice never goes out
			// of bounds. The compacted turn's tokens are then under-counted, which is
			// acceptable for a soft budget guard (a crash is not).
			if lastSeen > len(agentCtx.Messages) {
				lastSeen = len(agentCtx.Messages)
			}
			outputTokens, hadTool := goalTurnActivity(agentCtx.Messages[lastSeen:])
			lastSeen = len(agentCtx.Messages)
			host.Goal().RecordIteration(outputTokens, hadTool)

			cont, terminal := goalFollowUpDecision(host.Goal().Snapshot())
			if !cont {
				if terminal != agenttool.GoalIdle {
					host.Goal().SetStatus(terminal)
				}
				return nil
			}
			return []agentcore.AgentMessage{agentcore.UserMessage{
				RoleField: agentcore.RoleUser,
				Content:   agentcore.ContentList{agentcore.NewTextContent(goalContinuationPrompt)},
			}}
		},
	}

	// Wire the per-turn hook seams (PreToolUse/PostToolUse/Stop) onto this goal
	// run's cfg from the session dispatcher; a nil dispatcher is a no-op (FR-18).
	if d := host.Dispatcher(); d != nil {
		run.InstallSeams(&cfg, d, host.HookDeps())
	}

	// The first turn is driven by the goal reminder alone (the objective is
	// injected as background context); no explicit user prompt is appended so the
	// objective is not duplicated in the durable history.
	stream := runtime.StartRun(runCtx, host.AgentCtx(), cfg)
	drainGoalStream(runCtx, out, host, stream)

	printGoalOutcome(out, host.Goal().Snapshot())
	cli.PersistTurn(out, host)
}

// goalToolRegistry returns a registry holding every tool from base plus the two
// goal-control tools, so the autonomous run can invoke goal_complete/goal_blocked
// while keeping all the normal tools available. The base registry is left
// unchanged (the goal tools are only present for the goal run).
func goalToolRegistry(base *agenttool.ToolRegistry, state *agenttool.GoalState) *agenttool.ToolRegistry {
	reg := agenttool.NewToolRegistry()
	if base != nil {
		for _, t := range base.List() {
			_ = reg.Register(t)
		}
	}
	_ = reg.Register(&agenttool.GoalCompleteTool{State: state})
	_ = reg.Register(&agenttool.GoalBlockedTool{State: state})
	return reg
}

// goalReminders builds the per-turn reminder registry for a goal run: the goal
// reminder (re-stating the objective every turn) plus the todo reminder when a
// todo tool is present, so an autonomous run keeps both its objective and its
// task list in view.
func goalReminders(base *agenttool.ToolRegistry, state *agenttool.GoalState) *runtime.ReminderRegistry {
	reg := runtime.NewReminderRegistry(&runtime.GoalReminderProvider{State: state})
	if base != nil {
		if t, ok := base.Get("todo"); ok {
			if tt, ok := t.(*agenttool.TodoTool); ok && tt.Store != nil {
				reg.Register(&runtime.TodoReminderProvider{Store: tt.Store})
			}
		}
	}
	return reg
}

// goalTurnActivity sums the output tokens across the assistant messages in tail
// and reports whether any tool ran in that window (an assistant tool call or a
// tool result). It feeds RecordIteration's token-budget and no-progress inputs.
func goalTurnActivity(tail []agentcore.AgentMessage) (outputTokens int, hadTool bool) {
	for _, m := range tail {
		switch msg := m.(type) {
		case agentcore.AssistantMessage:
			if msg.Usage != nil {
				outputTokens += msg.Usage.OutputTokens
			}
			if len(msg.ToolCalls()) > 0 {
				hadTool = true
			}
		case agentcore.ToolResultMessage:
			hadTool = true
		}
	}
	return outputTokens, hadTool
}

// drainGoalStream prints the streamed assistant text and tool activity of a goal
// run to out, mirroring streamRun's rendering. It blocks until the run ends.
// chainGoalEvent returns the OnEvent observer for a goal run: the plugin
// notifier, with the SessionEnd/PreCompact hook notifier chained after it when
// hooks are configured, mirroring the REPL's OnEvent composition.
func chainGoalEvent(host cli.Host) func(agentcore.AgentEvent) {
	notifier := host.NotifierHandle()
	d := host.Dispatcher()
	if d == nil {
		return notifier
	}
	deps := host.HookDeps()
	hookEvent := hooks.NewHookNotifier(d, deps.SessionID, deps.ProjectDir).Handle
	if notifier == nil {
		return hookEvent
	}
	return func(ev agentcore.AgentEvent) {
		notifier(ev)
		hookEvent(ev)
	}
}

func drainGoalStream(ctx context.Context, out io.Writer, host cli.Host, stream *runtime.LoopEventStream) {
	var reply strings.Builder
	flushReply := func() {
		if reply.Len() == 0 {
			return
		}
		rendered := ui.RenderMarkdown(reply.String())
		fmt.Fprint(out, rendered)
		if !strings.HasSuffix(rendered, "\n") {
			fmt.Fprintln(out)
		}
		reply.Reset()
	}
	_, err := runtime.DrainStream(ctx, stream, runtime.StreamHandler{
		OnEvent: chainGoalEvent(host),
		OnText: func(delta string) {
			reply.WriteString(delta)
		},
		OnTurnEnd: func(msg agentcore.AssistantMessage, results []agentcore.ToolResultMessage) {
			flushReply()
			for _, c := range msg.ToolCalls() {
				fmt.Fprintf(out, "  %s %s\n", ui.Colorize(ui.Enabled(), ui.Green, "→ tool:"), ui.ToolCallLabel(c))
			}
			for _, tr := range results {
				ui.RenderToolResult(out, tr)
			}
		},
	})
	flushReply()
	if err != nil {
		if ctx.Err() != nil {
			fmt.Fprintln(out, "^C interrupted — goal paused (run /goal resume to continue)")
			host.Goal().SetStatus(agenttool.GoalPaused)
		} else {
			fmt.Fprintf(out, "error: %v\n", err)
		}
	}
}

// printGoalOutcome prints a one-line result banner after a goal run settles,
// keyed on the terminal status the run reached.
func printGoalOutcome(out io.Writer, snap agenttool.GoalSnapshot) {
	color := ui.Enabled()
	switch snap.Status {
	case agenttool.GoalComplete:
		fmt.Fprintf(out, "%s goal complete: %s\n", ui.Colorize(color, ui.Green, "✓"), snap.Summary)
	case agenttool.GoalBlocked:
		fmt.Fprintf(out, "%s goal blocked: %s\n", ui.Colorize(color, ui.Red, "⚠"), snap.BlockReason)
	case agenttool.GoalBudgetLimited:
		fmt.Fprintf(out, "%s goal paused — token budget reached (%d / %d). Run /goal resume to continue.\n",
			ui.Colorize(color, ui.Yellow, "⏸"), snap.TokensUsed, snap.TokenBudget)
	case agenttool.GoalPaused:
		fmt.Fprintf(out, "%s goal paused after %d turns. Run /goal resume to continue, or /goal clear to drop it.\n",
			ui.Colorize(color, ui.Yellow, "⏸"), snap.Iterations)
	}
}

// printGoalStatus prints a summary of the current goal state for a bare /goal.
func printGoalStatus(out io.Writer, goal *agenttool.GoalState) {
	snap := goal.Snapshot()
	if snap.Status == agenttool.GoalIdle {
		fmt.Fprintln(out, "no goal set — run /goal <objective> to start one")
		return
	}
	fmt.Fprintf(out, "goal:       %s\n", snap.Objective)
	fmt.Fprintf(out, "status:     %s\n", snap.Status)
	fmt.Fprintf(out, "iterations: %d\n", snap.Iterations)
	if snap.TokenBudget > 0 {
		fmt.Fprintf(out, "tokens:     %d / %d\n", snap.TokensUsed, snap.TokenBudget)
	} else {
		fmt.Fprintf(out, "tokens:     %d (no budget)\n", snap.TokensUsed)
	}
	if !snap.StartedAt.IsZero() {
		fmt.Fprintf(out, "elapsed:    %s\n", time.Since(snap.StartedAt).Round(time.Second))
	}
	if snap.Summary != "" {
		fmt.Fprintf(out, "summary:    %s\n", snap.Summary)
	}
	if snap.BlockReason != "" {
		fmt.Fprintf(out, "blocked:    %s\n", snap.BlockReason)
	}
}
