// Package headless drives pigo's non-interactive run paths: the print /
// stream-json headless run, the session listing/resume helpers, and the
// process-isolated sub-agent JSON-RPC server (--subagent-rpc).
//
// This file gives headless / stream-json runs the same session persistence and
// resume the interactive REPL has (cmd/pigo/interactive.go). Before this, a
// headless run built an in-memory AgentContext and threw it away on exit, so
// `--output-format stream-json` emitted no session id and `--resume`/`--continue`
// only worked in the REPL.
//
// Now a headless run is backed by a session file: resuming seeds the context
// from a prior session (and re-anchors the branch leaf), a fresh run creates a
// new session, and in both cases the run's newly produced messages are appended
// after it completes. The session id is threaded into the run so it appears in
// the first stream-json event (mirrors pi/Claude Code) and can be passed back via
// --resume to continue the run.
package headless

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"time"

	"github.com/smallnest/pigo/internal/agentcore"
	"github.com/smallnest/pigo/internal/session"
)

// SessionStore returns the session store rooted at ~/.pigo/sessions (or under
// PIGO_HOME when set), creating the directory on first use. It is shared by the
// headless run path and the interactive REPL.
func SessionStore() (*session.Store, error) {
	dir := os.Getenv("PIGO_HOME")
	if dir == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			return nil, fmt.Errorf("resolve home dir: %w", err)
		}
		dir = filepath.Join(home, ".pigo")
	}
	return session.NewStore(filepath.Join(dir, "sessions"))
}

// PrintSessions prints the stored sessions, most-recent first, to out.
func PrintSessions(out io.Writer) error {
	store, err := SessionStore()
	if err != nil {
		return err
	}
	headers, err := store.List()
	if err != nil {
		return err
	}
	if len(headers) == 0 {
		fmt.Fprintln(out, "no sessions")
		return nil
	}
	for _, h := range headers {
		fmt.Fprintf(out, "%s\t%s\t%s\n", h.ID, h.UpdatedAt.Local().Format("2006-01-02 15:04"), h.Model)
	}
	return nil
}

// MostRecentSessionID returns the id of the most recently updated session, or
// "" if there are none.
func MostRecentSessionID() (string, error) {
	store, err := SessionStore()
	if err != nil {
		return "", err
	}
	headers, err := store.List()
	if err != nil {
		return "", err
	}
	if len(headers) == 0 {
		return "", nil
	}
	return headers[0].ID, nil
}

// headlessSession is the session state backing one headless run: the store, the
// header (whose ID is the session id emitted and used for resume), and the
// branch-tracking cursor (curLeaf/persisted) so the run's messages append as a
// branch descending from the resumed leaf rather than flattening the tree.
type headlessSession struct {
	store   *session.Store
	header  session.SessionHeader
	curLeaf string // active leaf id to descend from; "" for a fresh session
	// persisted is the number of agentCtx.Messages already on disk before the
	// run; persist appends only Messages[persisted:] as a new branch.
	persisted int
	// model/provider are the model and provider the run actually used, refreshed
	// onto the header before persisting so a resumed run does not write back the
	// original session's stale values (matching the REPL, repl.go persistTurn).
	model    string
	provider string
}

// openHeadlessSession resolves the session backing a headless run: it resumes an
// existing session when resumeID is set (seeding priorMsgs and re-anchoring the
// branch leaf) or creates a fresh session header otherwise. It returns the prior
// messages to seed into the context ahead of the new prompt, plus the session
// state used to persist the run afterward.
func openHeadlessSession(resumeID, model, providerName, sysPrompt string) (agentcore.MessageList, headlessSession, error) {
	store, err := SessionStore()
	if err != nil {
		return nil, headlessSession{}, err
	}
	now := time.Now().UTC()

	if resumeID != "" {
		h, entries, err := store.LoadEntries(resumeID)
		if err != nil {
			return nil, headlessSession{}, err
		}
		msgs := make(agentcore.MessageList, len(entries))
		for i, e := range entries {
			msgs[i] = e.Message
		}
		curLeaf := ""
		if len(entries) > 0 {
			curLeaf = entries[len(entries)-1].ID
		}
		// A resumed header keeps its own SystemPrompt when present so the run is
		// faithful to the original session.
		if h.SystemPrompt == "" {
			h.SystemPrompt = sysPrompt
		}
		return msgs, headlessSession{store: store, header: h, curLeaf: curLeaf, persisted: len(msgs), model: model, provider: providerName}, nil
	}

	header := session.SessionHeader{
		ID:           session.NewID(now),
		CreatedAt:    now,
		UpdatedAt:    now,
		Model:        model,
		Provider:     providerName,
		SystemPrompt: sysPrompt,
		Cwd:          headlessCwd(),
	}
	return nil, headlessSession{store: store, header: header, curLeaf: "", persisted: 0, model: model, provider: providerName}, nil
}

// headlessCwd returns the absolute working directory the run executes in, used
// to attribute the session to a project (SessionHeader.Cwd → project id) so a
// later /dream pass can distill this session under the right project scope. An
// unresolvable cwd yields "" (the session stays unattributed) rather than
// aborting the run.
func headlessCwd() string {
	wd, err := os.Getwd()
	if err != nil {
		return ""
	}
	return wd
}

// persist appends the messages produced during the run — everything in
// agentCtx.Messages past what was already on disk — as a branch descending from
// the resumed leaf, matching how the REPL grows a session tree (AppendBranch).
// It is a no-op when the run produced nothing new. Errors are returned for the
// caller to surface; the run's output has already been emitted regardless.
func (hs *headlessSession) persist(agentCtx *agentcore.AgentContext) error {
	// Compaction can rebuild agentCtx.Messages to fewer entries than were on disk
	// before the run (loop.go maybeAutoCompact replaces the slice). Clamp the
	// cursor so the tail slice stays in bounds; when the context shrank there is
	// nothing new to append past what compaction kept.
	if hs.persisted > len(agentCtx.Messages) {
		hs.persisted = len(agentCtx.Messages)
	}
	tail := agentCtx.Messages[hs.persisted:]
	if len(tail) == 0 {
		return nil
	}
	// Refresh the header with the model/provider the run actually used so a
	// resumed session's metadata is not written back stale (matching the REPL).
	hs.header.Model = hs.model
	hs.header.Provider = hs.provider
	hs.header.UpdatedAt = time.Now().UTC()
	if _, err := hs.store.AppendBranch(hs.header, hs.curLeaf, tail); err != nil {
		return err
	}
	hs.persisted = len(agentCtx.Messages)
	return nil
}
