package history

import (
	"context"
	"encoding/json"
	"fmt"
	"maps"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"reasonix/internal/agent"
	fileencoding "reasonix/internal/fileutil/encoding"
	"reasonix/internal/provider"
	"reasonix/internal/retrieval"
	"reasonix/internal/store"
)

// Kind identifies the part of a saved message indexed for retrieval.
type Kind string

const (
	KindUserText      Kind = "user_text"
	KindAssistantText Kind = "assistant_text"
	KindToolInput     Kind = "tool_input"
	KindToolError     Kind = "tool_error"
	KindToolOutput    Kind = "tool_output"
)

const (
	scopeProject = "project"
	scopeGlobal  = "global"

	defaultLimit  = 8
	maxLimit      = 20
	defaultAround = 3
	maxAround     = 10
	maxSnippet    = 240
	scoreFloor    = 0.15
)

var defaultKinds = map[Kind]bool{
	KindUserText:      true,
	KindAssistantText: true,
	KindToolInput:     true,
	KindToolError:     true,
}

// Options binds a Searcher to the session/history roots it may read.
type Options struct {
	// SessionDir is the current controller's session directory. In desktop this
	// is usually project-scoped; in CLI it is often the user-global session dir.
	SessionDir string
	// GlobalSessionDir is the user-global session directory. It is searched only
	// when the caller asks for global scope, and may equal SessionDir.
	GlobalSessionDir string
	ArchiveDir       string
}

// Searcher performs lightweight BM25 retrieval over saved session JSONL files.
type Searcher struct {
	sessionDir       string
	globalSessionDir string
	archiveDir       string
}

// NewSearcher returns a searcher confined to the supplied directories.
func NewSearcher(opts Options) *Searcher {
	return &Searcher{
		sessionDir:       strings.TrimSpace(opts.SessionDir),
		globalSessionDir: strings.TrimSpace(opts.GlobalSessionDir),
		archiveDir:       strings.TrimSpace(opts.ArchiveDir),
	}
}

// SearchRequest describes a history search.
type SearchRequest struct {
	Query    string
	Scope    string
	Kinds    []Kind
	ToolName string
	Limit    int
}

// AroundRequest fetches messages adjacent to a search hit.
type AroundRequest struct {
	SessionPath  string
	MessageIndex int
	Before       int
	After        int
}

// Hit is a ranked search result.
type Hit struct {
	Score        float64
	SessionPath  string
	SessionID    string
	Source       string
	MessageIndex int
	Role         provider.Role
	Kind         Kind
	ToolName     string
	Snippet      string
}

// MessageContext is one message returned by Around.
type MessageContext struct {
	Index int
	Text  string
}

type sourceFile struct {
	path   string
	source string
	mod    int64
}

type document struct {
	source       sourceFile
	messageIndex int
	role         provider.Role
	kind         Kind
	toolName     string
	text         string
	counts       map[string]int
	length       int
}

// Search ranks saved history by BM25. It indexes only the selected documents for
// this call, which keeps the implementation dependency-free and cache-neutral.
func (s *Searcher) Search(ctx context.Context, req SearchRequest) ([]Hit, error) {
	query := strings.TrimSpace(req.Query)
	if query == "" {
		return nil, fmt.Errorf("query is required")
	}
	queryTerms, err := retrieval.QueryTerms(query)
	if err != nil {
		return nil, err
	}
	scope, err := normalizeScope(req.Scope)
	if err != nil {
		return nil, err
	}
	limit := clamp(req.Limit, defaultLimit, maxLimit)
	kindSet, err := normalizeKinds(req.Kinds)
	if err != nil {
		return nil, err
	}
	toolName := strings.TrimSpace(req.ToolName)

	sources, err := s.sources(scope)
	if err != nil {
		return nil, err
	}
	var docs []document
	for _, src := range sources {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		msgs, err := loadMessages(src.path)
		if err != nil {
			continue
		}
		docs = append(docs, extractDocuments(src, msgs, kindSet, toolName)...)
	}
	if len(docs) == 0 {
		return nil, nil
	}

	df := map[string]int{}
	totalLen := 0
	for i := range docs {
		totalLen += docs[i].length
		seen := map[string]bool{}
		for term := range docs[i].counts {
			if !seen[term] {
				df[term]++
				seen[term] = true
			}
		}
	}
	avgLen := float64(totalLen) / float64(len(docs))
	if avgLen <= 0 {
		avgLen = 1
	}

	var hits []Hit
	for _, doc := range docs {
		score := retrieval.BM25Score(doc.counts, doc.length, queryTerms, df, len(docs), avgLen)
		if score <= 0 {
			continue
		}
		hits = append(hits, Hit{
			Score:        score,
			SessionPath:  doc.source.path,
			SessionID:    sessionID(doc.source.path),
			Source:       doc.source.source,
			MessageIndex: doc.messageIndex,
			Role:         doc.role,
			Kind:         doc.kind,
			ToolName:     doc.toolName,
			Snippet:      retrieval.MakeSnippet(doc.text, query, queryTerms, maxSnippet),
		})
	}
	sort.Slice(hits, func(i, j int) bool {
		if hits[i].Score == hits[j].Score {
			if hits[i].SessionPath == hits[j].SessionPath {
				return hits[i].MessageIndex < hits[j].MessageIndex
			}
			return hits[i].SessionPath < hits[j].SessionPath
		}
		return hits[i].Score > hits[j].Score
	})
	hits = retrieval.KeepTopRelativeScore(hits, scoreFloor, func(hit Hit) float64 {
		return hit.Score
	})
	if len(hits) > limit {
		hits = hits[:limit]
	}
	return hits, nil
}

// Around returns a compact transcript window around a saved message.
func (s *Searcher) Around(ctx context.Context, req AroundRequest) ([]MessageContext, error) {
	path := strings.TrimSpace(req.SessionPath)
	if path == "" {
		return nil, fmt.Errorf("session_path is required")
	}
	if req.MessageIndex < 0 {
		return nil, fmt.Errorf("message_index must be non-negative")
	}
	if !s.allowedPath(path) {
		return nil, fmt.Errorf("session_path is outside the configured history roots")
	}
	if !s.visiblePath(path) {
		return nil, fmt.Errorf("session_path is pending cleanup")
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	msgs, err := loadMessages(path)
	if err != nil {
		return nil, err
	}
	if req.MessageIndex >= len(msgs) {
		return nil, fmt.Errorf("message_index %d is outside session length %d", req.MessageIndex, len(msgs))
	}
	before := clamp(req.Before, defaultAround, maxAround)
	after := clamp(req.After, defaultAround, maxAround)
	start := max(req.MessageIndex-before, 0)
	remainingAfter := len(msgs) - req.MessageIndex - 1
	end := len(msgs)
	if after < remainingAfter {
		end = len(msgs) - (remainingAfter - after)
	}
	out := make([]MessageContext, 0, end-start)
	for i := start; i < end; i++ {
		out = append(out, MessageContext{Index: i, Text: renderMessage(i, msgs[i])})
	}
	return out, nil
}

func normalizeScope(scope string) (string, error) {
	switch strings.TrimSpace(scope) {
	case "", scopeProject:
		return scopeProject, nil
	case scopeGlobal:
		return scopeGlobal, nil
	default:
		return "", fmt.Errorf("scope must be %q or %q", scopeProject, scopeGlobal)
	}
}

func normalizeKinds(kinds []Kind) (map[Kind]bool, error) {
	if len(kinds) == 0 {
		out := make(map[Kind]bool, len(defaultKinds))
		maps.Copy(out, defaultKinds)
		return out, nil
	}
	out := map[Kind]bool{}
	for _, k := range kinds {
		switch k {
		case KindUserText, KindAssistantText, KindToolInput, KindToolError, KindToolOutput:
			out[k] = true
		default:
			return nil, fmt.Errorf("unknown kind %q", k)
		}
	}
	return out, nil
}

func (s *Searcher) sources(scope string) ([]sourceFile, error) {
	var out []sourceFile
	seen := map[string]bool{}
	out = appendSessionSources(out, seen, s.sessionDir, scopeProject)
	if scope == scopeGlobal {
		out = appendSessionSources(out, seen, s.globalSessionDir, scopeGlobal)
		out = appendFiles(out, seen, listJSONL(s.archiveDir, "archive", nil)...)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].mod == out[j].mod {
			return out[i].path < out[j].path
		}
		return out[i].mod > out[j].mod
	})
	return out, nil
}

func appendSessionSources(out []sourceFile, seen map[string]bool, dir, source string) []sourceFile {
	out = appendFiles(out, seen, listJSONL(dir, source, agent.IsVisibleSession)...)
	if strings.TrimSpace(dir) != "" {
		out = appendFiles(out, seen, listJSONL(subagentsDir(dir), source, func(path string) bool {
			return visibleSubagentSession(dir, path)
		})...)
	}
	return out
}

func appendFiles(out []sourceFile, seen map[string]bool, files ...sourceFile) []sourceFile {
	for _, file := range files {
		key := file.path
		if abs, err := filepath.Abs(file.path); err == nil {
			key = abs
		}
		if seen[key] {
			continue
		}
		seen[key] = true
		out = append(out, file)
	}
	return out
}

func listJSONL(dir, source string, visible func(string) bool) []sourceFile {
	if strings.TrimSpace(dir) == "" {
		return nil
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil
	}
	var out []sourceFile
	for _, entry := range entries {
		if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) {
			continue
		}
		info, err := entry.Info()
		if err != nil {
			continue
		}
		path := filepath.Join(dir, entry.Name())
		if visible != nil && !visible(path) {
			continue
		}
		// Recency must track the event log too: the .jsonl checkpoint's mtime
		// only moves at checkpoints.
		mod := info.ModTime()
		if contentMod := agent.SessionContentModTime(path); !contentMod.IsZero() {
			mod = contentMod
		}
		out = append(out, sourceFile{
			path:   path,
			source: source,
			mod:    mod.UnixNano(),
		})
	}
	return out
}

func loadMessages(path string) ([]provider.Message, error) {
	sess, err := agent.LoadSession(path)
	if err != nil {
		return nil, err
	}
	return sess.Snapshot(), nil
}

func extractDocuments(src sourceFile, msgs []provider.Message, kinds map[Kind]bool, toolName string) []document {
	var docs []document
	for i, msg := range msgs {
		switch msg.Role {
		case provider.RoleUser:
			if kinds[KindUserText] && strings.TrimSpace(msg.Content) != "" {
				docs = appendDoc(docs, src, i, msg.Role, KindUserText, "", stripComposePrefixes(msg.Content))
			}
		case provider.RoleAssistant:
			if kinds[KindAssistantText] && strings.TrimSpace(msg.Content) != "" {
				docs = appendDoc(docs, src, i, msg.Role, KindAssistantText, "", msg.Content)
			}
			if kinds[KindToolInput] {
				for _, call := range msg.ToolCalls {
					if toolName != "" && call.Name != toolName {
						continue
					}
					text := strings.TrimSpace(call.Name + " " + call.Arguments)
					docs = appendDoc(docs, src, i, msg.Role, KindToolInput, call.Name, text)
				}
			}
		case provider.RoleTool:
			if toolName != "" && msg.Name != toolName {
				continue
			}
			if kinds[KindToolError] && isToolError(msg.Content) {
				docs = appendDoc(docs, src, i, msg.Role, KindToolError, msg.Name, msg.Name+" "+msg.Content)
			}
			if kinds[KindToolOutput] {
				docs = appendDoc(docs, src, i, msg.Role, KindToolOutput, msg.Name, msg.Name+" "+msg.Content)
			}
		}
	}
	return docs
}

func appendDoc(docs []document, src sourceFile, idx int, role provider.Role, kind Kind, toolName, text string) []document {
	text = strings.TrimSpace(text)
	if text == "" {
		return docs
	}
	terms := retrieval.Tokens(text)
	if len(terms) == 0 {
		return docs
	}
	counts := retrieval.Counts(terms)
	return append(docs, document{
		source:       src,
		messageIndex: idx,
		role:         role,
		kind:         kind,
		toolName:     toolName,
		text:         text,
		counts:       counts,
		length:       len(terms),
	})
}

func isToolError(content string) bool {
	s := strings.ToLower(strings.TrimSpace(content))
	return strings.HasPrefix(s, "error:") ||
		strings.HasPrefix(s, "blocked:") ||
		strings.Contains(s, "permission denied")
}

func sessionID(path string) string {
	base := filepath.Base(path)
	return strings.TrimSuffix(base, filepath.Ext(base))
}

func renderMessage(idx int, msg provider.Message) string {
	var b strings.Builder
	switch msg.Role {
	case provider.RoleUser:
		fmt.Fprintf(&b, "[%d user]\n%s", idx, truncate(stripComposePrefixes(msg.Content), 2000))
	case provider.RoleAssistant:
		if strings.TrimSpace(msg.Content) != "" {
			fmt.Fprintf(&b, "[%d assistant]\n%s", idx, truncate(msg.Content, 2000))
		} else {
			fmt.Fprintf(&b, "[%d assistant]", idx)
		}
		for _, call := range msg.ToolCalls {
			fmt.Fprintf(&b, "\n[tool call: %s]\n%s", call.Name, truncate(call.Arguments, 1200))
		}
	case provider.RoleTool:
		fmt.Fprintf(&b, "[%d tool %s result]\n%s", idx, msg.Name, truncate(msg.Content, 2000))
	case provider.RoleSystem:
		fmt.Fprintf(&b, "[%d system]\n%s", idx, truncate(msg.Content, 1200))
	default:
		fmt.Fprintf(&b, "[%d %s]\n%s", idx, msg.Role, truncate(msg.Content, 2000))
	}
	return strings.TrimSpace(b.String())
}

func truncate(s string, maxRunes int) string {
	s = strings.TrimSpace(s)
	runes := []rune(s)
	if len(runes) <= maxRunes {
		return s
	}
	return string(runes[:maxRunes]) + "..."
}

func clamp(n, def, max int) int {
	if n <= 0 {
		return def
	}
	if n > max {
		return max
	}
	return n
}

func (s *Searcher) visiblePath(path string) bool {
	switch {
	case underRoot(path, subagentsDir(s.sessionDir)):
		return visibleSubagentSession(s.sessionDir, path)
	case underRoot(path, s.sessionDir):
		return agent.IsVisibleSession(path)
	case underRoot(path, subagentsDir(s.globalSessionDir)):
		return visibleSubagentSession(s.globalSessionDir, path)
	case underRoot(path, s.globalSessionDir):
		return agent.IsVisibleSession(path)
	case underRoot(path, s.archiveDir):
		return true
	default:
		return false
	}
}

func visibleSubagentSession(sessionDir, path string) bool {
	if !agent.IsVisibleSession(path) {
		return false
	}
	parentSession, ok := subagentParentSession(path)
	if !ok || parentSession == "" {
		return true
	}
	return !agent.IsCleanupPending(filepath.Join(sessionDir, parentSession+".jsonl"))
}

func subagentParentSession(path string) (string, bool) {
	ref := strings.TrimSuffix(filepath.Base(path), ".jsonl")
	if ref == "" || ref == filepath.Base(path) {
		return "", false
	}
	b, err := fileencoding.ReadFileUTF8(filepath.Join(filepath.Dir(path), ref+".meta.json"))
	if err != nil {
		return "", false
	}
	var meta agent.SubagentMeta
	if err := json.Unmarshal(b, &meta); err != nil {
		return "", false
	}
	return strings.TrimSpace(meta.ParentSession), true
}

func subagentsDir(dir string) string {
	if strings.TrimSpace(dir) == "" {
		return ""
	}
	return filepath.Join(dir, "subagents")
}

func (s *Searcher) allowedPath(path string) bool {
	roots := []string{s.sessionDir, s.globalSessionDir, s.archiveDir}
	if s.sessionDir != "" {
		roots = append(roots, subagentsDir(s.sessionDir))
	}
	if s.globalSessionDir != "" {
		roots = append(roots, subagentsDir(s.globalSessionDir))
	}
	for _, root := range roots {
		if underRoot(path, root) {
			return true
		}
	}
	return false
}

func underRoot(path, root string) bool {
	if strings.TrimSpace(path) == "" || strings.TrimSpace(root) == "" {
		return false
	}
	absPath, err := filepath.Abs(path)
	if err != nil {
		return false
	}
	absRoot, err := filepath.Abs(root)
	if err != nil {
		return false
	}
	rel, err := filepath.Rel(absRoot, absPath)
	if err != nil {
		return false
	}
	return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}

// MarshalJSON keeps Hit stable if frontends choose to expose the same data later.
func (h Hit) MarshalJSON() ([]byte, error) {
	type hit struct {
		Score        float64       `json:"score"`
		SessionPath  string        `json:"session_path"`
		SessionID    string        `json:"session_id"`
		Source       string        `json:"source"`
		MessageIndex int           `json:"message_index"`
		Role         provider.Role `json:"role"`
		Kind         Kind          `json:"kind"`
		ToolName     string        `json:"tool_name,omitempty"`
		Snippet      string        `json:"snippet"`
	}
	return json.Marshal(hit(h))
}
