package services

import (
	"encoding/json"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"

	"sproutclaw-web/internal/db"
	"sproutclaw-web/internal/models"
)

// sessionLine is one parsed line of a session JSONL file.
type sessionLine struct {
	Type      string          `json:"type"`
	ID        string          `json:"id"`
	Timestamp string          `json:"timestamp"`
	Name      string          `json:"name"`
	Message   json.RawMessage `json:"message"`
}

type sessionMessage struct {
	Role    string          `json:"role"`
	Content json.RawMessage `json:"content"`
}

var (
	reHex    = regexp.MustCompile(`^[0-9a-fA-F]{8,}$`)
	reDigits = regexp.MustCompile(`^[0-9]{10,}$`)
	reWS     = regexp.MustCompile(`\s+`)
	reFirstSentence = regexp.MustCompile(`^(.+?[。！？.!?])(\s|$)`)
)

// listSessionFiles recursively collects every *.jsonl under sessionsDir.
// pi stores sessions in cwd-encoded subdirectories, e.g.
//   sessions/--D--SmyProjects-AI-sproutclaw--/<id>.jsonl
func listSessionFiles(sessionsDir string) []string {
	var files []string
	_ = filepath.WalkDir(sessionsDir, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return nil
		}
		if !d.IsDir() && strings.HasSuffix(d.Name(), ".jsonl") {
			files = append(files, path)
		}
		return nil
	})
	// newest first by path (ids are time-sortable); final sort happens later
	sort.Sort(sort.Reverse(sort.StringSlice(files)))
	return files
}

func isMachineSessionLabel(text, headerID string) bool {
	t := strings.TrimSpace(text)
	if t == "" {
		return true
	}
	if headerID != "" && t == headerID {
		return true
	}
	if reHex.MatchString(t) || reDigits.MatchString(t) {
		return true
	}
	return false
}

func titleFromFirstUserMessage(text string) string {
	const maxChars = 56
	cleaned := strings.TrimSpace(reWS.ReplaceAllString(text, " "))
	if cleaned == "" {
		return ""
	}
	candidate := cleaned
	if m := reFirstSentence.FindStringSubmatch(cleaned); m != nil && m[1] != "" {
		candidate = strings.TrimSpace(m[1])
	}
	runes := []rune(candidate)
	if len(runes) > maxChars {
		candidate = strings.TrimRight(string(runes[:maxChars]), " ") + "…"
	}
	return candidate
}

func extractPreview(msg sessionMessage) string {
	if len(msg.Content) == 0 {
		return ""
	}
	// content may be a string
	var s string
	if json.Unmarshal(msg.Content, &s) == nil {
		return truncate(s, 200)
	}
	// or an array of blocks
	var blocks []map[string]any
	if json.Unmarshal(msg.Content, &blocks) == nil {
		var sb strings.Builder
		imageCount := 0
		for _, b := range blocks {
			switch b["type"] {
			case "text":
				if t, ok := b["text"].(string); ok {
					sb.WriteString(t)
				}
			case "image":
				imageCount++
			}
		}
		if txt := sb.String(); txt != "" {
			return truncate(txt, 200)
		}
		if imageCount > 0 {
			return "[" + itoa(imageCount) + " 张图片]"
		}
	}
	return ""
}

// readSessionSummary parses one session file into a summary (nil if not a session).
func readSessionSummary(path string) (models.SessionSummary, bool) {
	data, err := os.ReadFile(path)
	if err != nil {
		return models.SessionSummary{}, false
	}
	lines := strings.Split(strings.TrimSpace(string(data)), "\n")
	if len(lines) == 0 {
		return models.SessionSummary{}, false
	}

	var header sessionLine
	if json.Unmarshal([]byte(lines[0]), &header) != nil || header.Type != "session" {
		return models.SessionSummary{}, false
	}

	info, _ := os.Stat(path)
	modified := ""
	if info != nil {
		modified = info.ModTime().UTC().Format(time.RFC3339)
	}

	nameFromInfo := ""
	messageCount := 0
	firstMessage := ""

	for _, line := range lines {
		if strings.TrimSpace(line) == "" {
			continue
		}
		var entry sessionLine
		if json.Unmarshal([]byte(line), &entry) != nil {
			continue
		}
		switch entry.Type {
		case "session_info":
			if entry.Name != "" {
				n := strings.TrimSpace(entry.Name)
				if n != "" && !isMachineSessionLabel(n, header.ID) {
					nameFromInfo = n
				}
			}
		case "message":
			messageCount++
			if firstMessage == "" && len(entry.Message) > 0 {
				var m sessionMessage
				if json.Unmarshal(entry.Message, &m) == nil && m.Role == "user" {
					firstMessage = extractPreview(m)
				}
			}
		}
	}

	name := nameFromInfo
	if name == "" || isMachineSessionLabel(name, header.ID) {
		name = titleFromFirstUserMessage(firstMessage)
	}

	preview := firstMessage
	if preview == "" {
		preview = "(空)"
	}

	return models.SessionSummary{
		Path:         path,
		Name:         name,
		Created:      header.Timestamp,
		Modified:     modified,
		MessageCount: messageCount,
		FirstMessage: preview,
	}, true
}

// BuildSessionList recursively reads all sessions and assembles the response.
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
	if _, err := os.Stat(sessionsDir); err != nil {
		return nil, nil
	}

	pinned, _ := database.GetPinnedSessions()
	pinnedOrder := map[string]int{}
	for i, p := range pinned {
		pinnedOrder[filepath.Clean(p)] = i
	}

	var summaries []models.SessionSummary
	for _, f := range listSessionFiles(sessionsDir) {
		sum, ok := readSessionSummary(f)
		if !ok {
			continue
		}
		_, sum.Pinned = pinnedOrder[filepath.Clean(f)]
		summaries = append(summaries, sum)
	}

	sort.SliceStable(summaries, func(i, j int) bool {
		ci, iPinned := pinnedOrder[filepath.Clean(summaries[i].Path)]
		cj, jPinned := pinnedOrder[filepath.Clean(summaries[j].Path)]
		if iPinned && jPinned {
			return ci < cj
		}
		if iPinned != jPinned {
			return iPinned
		}
		return sessionSortKey(summaries[i]) > sessionSortKey(summaries[j])
	})
	return summaries, nil
}

func sessionSortKey(s models.SessionSummary) string {
	if s.Modified != "" {
		return s.Modified
	}
	return s.Created
}

// ReadSessionSummaryByPath returns a single session's summary (exported).
func ReadSessionSummaryByPath(path string) (models.SessionSummary, error) {
	sum, ok := readSessionSummary(path)
	if !ok {
		return models.SessionSummary{Path: path}, nil
	}
	return sum, nil
}

// ReadSessionMessages returns the `message` payload of each message line.
func ReadSessionMessages(path string) ([]json.RawMessage, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var out []json.RawMessage
	for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
		if strings.TrimSpace(line) == "" {
			continue
		}
		var entry sessionLine
		if json.Unmarshal([]byte(line), &entry) != nil {
			continue
		}
		if entry.Type == "message" && len(entry.Message) > 0 {
			out = append(out, entry.Message)
		}
	}
	return out, nil
}

// AppendSessionName appends a session_info rename record to the JSONL file.
func AppendSessionName(path, name string) error {
	trimmed := strings.TrimSpace(name)
	if trimmed == "" {
		return os.ErrInvalid
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer f.Close()
	record := map[string]any{
		"type":      "session_info",
		"timestamp": time.Now().UTC().Format(time.RFC3339),
		"name":      trimmed,
	}
	b, err := json.Marshal(record)
	if err != nil {
		return err
	}
	_, err = f.Write(append([]byte("\n"), b...))
	return err
}

func truncate(s string, n int) string {
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	return string(r[:n])
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	neg := n < 0
	if neg {
		n = -n
	}
	var b [20]byte
	i := len(b)
	for n > 0 {
		i--
		b[i] = byte('0' + n%10)
		n /= 10
	}
	if neg {
		i--
		b[i] = '-'
	}
	return string(b[i:])
}
