package agent

import (
	"context"

	"github.com/smallnest/pigo/internal/agentcore"
	"github.com/smallnest/pigo/internal/cli/run"
	"github.com/smallnest/pigo/internal/provider"
	"github.com/smallnest/pigo/internal/runtime"
)

// Session is a single, stateful pigo agent conversation. Create one with New,
// drive it with Prompt or Stream, and release its resources with Close. The
// conversation history accumulates across calls, so follow-up prompts see the
// earlier exchange; call Reset to start over on the same session.
//
// A Session is not safe for concurrent use. Drive it from a single goroutine, or
// give each goroutine its own Session.
type Session struct {
	env      run.Env
	runCfg   runtime.RunConfig
	agentCtx *agentcore.AgentContext
	model    string
}

// New builds a Session from the given options. It resolves the provider and
// credentials, assembles the tool set, and validates the tool policy and
// thinking level up front, so a configuration mistake (an unknown tool name, an
// invalid thinking level, an unresolvable provider) is returned here rather than
// surfacing on the first Prompt.
//
// No network call is made by New: the provider is only contacted when you call
// Prompt or Stream. This makes New cheap and safe to use in tests.
//
// See the package documentation for the default tool, skill, and memory
// behavior — in particular, that tools are enabled and auto-executed by default.
func New(opts ...Option) (*Session, error) {
	c := config{model: "openrouter/free"}
	for _, o := range opts {
		o(&c)
	}

	// Validate the reasoning-effort level through the same layered config chain
	// the CLI uses, so an invalid WithThinkingLevel value fails fast here.
	thinking, err := run.ResolveThinkingLevel(c.thinking)
	if err != nil {
		return nil, err
	}

	// One ToolPolicy value carries both lists so they cannot be swapped; deny
	// always wins over allow inside run.ApplyToolPolicy.
	policy := run.NewToolPolicy(c.allowedTools, c.disallowedTools)

	// SetupEnv resolves the provider, assembles the (policy-filtered) tool set,
	// builds the system prompt, and — because skills/memory are opt-in here —
	// leaves the machine's shared state untouched unless WithSkills/WithMemory
	// were passed. It also validates the tool policy against the real tool set,
	// so an unknown tool name is reported as an error.
	env, err := run.SetupEnv(
		c.model, c.baseURL, c.protocol, c.provider, c.apiKey,
		c.noTools, !c.skills, c.systemPrompt, c.appendSystemPrompt, c.memory, policy,
	)
	if err != nil {
		return nil, err
	}

	// Resolve the API key by provider name: an explicit WithAPIKey overrides the
	// provider's environment variable. The key is held only in the credential
	// store and never logged.
	creds := provider.NewCredentialStore(nil)
	if c.apiKey != "" {
		creds.SetOverride(env.ProviderName, c.apiKey)
	}

	runCfg := run.NewConfig(
		c.model, env.ProviderName, thinking, env.Provider, creds,
		run.ToolRegistry(env.Tools), run.TodoReminders(env.Tools),
	)

	return &Session{
		env:    env,
		runCfg: runCfg,
		agentCtx: &agentcore.AgentContext{
			SystemPrompt: env.SysPrompt,
			Tools:        env.Tools,
		},
		model: c.model,
	}, nil
}

// Prompt sends one user message, runs the agent loop to completion (executing
// any tool calls the model makes along the way), and returns the assistant's
// final text. The exchange is appended to the session history so later prompts
// have this context.
func (s *Session) Prompt(ctx context.Context, prompt string) (string, error) {
	return s.Stream(ctx, prompt, nil)
}

// Stream is Prompt with incremental output: onText, if non-nil, is called with
// each chunk of assistant text as it arrives, and the complete final text is
// also returned. Tool calls still run automatically between text chunks. A nil
// onText makes Stream behave exactly like Prompt.
func (s *Session) Stream(ctx context.Context, prompt string, onText func(string)) (string, error) {
	// The loop expects the initiating user message already appended; it then
	// mutates agentCtx.Messages in place (assistant + tool results), which is
	// what carries the conversation forward across calls.
	s.agentCtx.Messages = append(s.agentCtx.Messages, agentcore.UserMessage{
		RoleField: agentcore.RoleUser,
		Content:   agentcore.ContentList{agentcore.NewTextContent(prompt)},
	})

	stream := runtime.StartRun(ctx, s.agentCtx, s.runCfg)
	final, err := runtime.DrainStream(ctx, stream, runtime.StreamHandler{OnText: onText})
	if err != nil {
		return "", err
	}
	if final == nil {
		return "", nil
	}
	return agentcore.ContentToText(final.Content), nil
}

// Reset clears the conversation history, so the next Prompt starts a fresh
// exchange. The provider, tool set, and system prompt are unchanged.
func (s *Session) Reset() {
	s.agentCtx.Messages = nil
}

// ToolNames returns the names of the tools available to this session, in the
// order they are advertised to the model. It reflects the applied tool policy,
// so it is a convenient way to confirm WithTools/WithDisallowedTools did what
// you intended. The result is empty for a WithoutTools session.
func (s *Session) ToolNames() []string {
	names := make([]string, len(s.env.Tools))
	for i, t := range s.env.Tools {
		names[i] = t.Name()
	}
	return names
}

// Model returns the model id the session was created with.
func (s *Session) Model() string { return s.model }

// Provider returns the resolved provider name (e.g. "anthropic", "openrouter"),
// which is inferred from the model id unless WithProvider was set.
func (s *Session) Provider() string { return s.env.ProviderName }

// Close releases resources held by the session: any loaded plugin manager and
// the persistent memory store (when WithMemory was used). It is safe to call
// once, and safe to call on a session that holds neither. After Close the
// session must not be used again.
func (s *Session) Close() error {
	var firstErr error
	if s.env.Plugins != nil {
		if err := s.env.Plugins.Close(); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	if s.env.Memory != nil {
		if err := s.env.Memory.Close(); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	return firstErr
}
