package database

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

	"go.uber.org/zap"
)

// ConversationPlanTask mirrors the public fields persisted by Eino plantask.
// Keeping the transport model here avoids coupling the HTTP layer to Eino's
// private task type.
type ConversationPlanTask struct {
	ID          string   `json:"id"`
	Subject     string   `json:"subject"`
	Description string   `json:"description,omitempty"`
	Status      string   `json:"status"`
	Blocks      []string `json:"blocks,omitempty"`
	BlockedBy   []string `json:"blockedBy,omitempty"`
	ActiveForm  string   `json:"activeForm,omitempty"`
	Owner       string   `json:"owner,omitempty"`
}

// ListConversationPlanTasks returns the live Eino task board for one
// conversation. A missing task directory is the normal state for short or
// legacy conversations and therefore returns an empty list.
func (db *DB) ListConversationPlanTasks(conversationID string) ([]ConversationPlanTask, error) {
	return db.ListConversationPlanTasksSince(conversationID, time.Time{})
}

// ListConversationPlanTasksSince limits the board to files written during the
// current agent run. The Eino backend intentionally keeps older task files for
// model continuity, but the conversation UI must not surface those files before
// the new run has called TaskCreate.
func (db *DB) ListConversationPlanTasksSince(conversationID string, since time.Time) ([]ConversationPlanTask, error) {
	if db == nil {
		return []ConversationPlanTask{}, nil
	}
	conversationID = strings.TrimSpace(conversationID)
	if conversationID == "" {
		return nil, fmt.Errorf("conversation id is required")
	}
	base := strings.TrimSpace(db.einoPlantaskBaseDir)
	if base == "" {
		return []ConversationPlanTask{}, nil
	}

	dir := filepath.Join(base, sanitizeConversationPathSegment(conversationID))
	entries, err := os.ReadDir(dir)
	if os.IsNotExist(err) {
		return []ConversationPlanTask{}, nil
	}
	if err != nil {
		return nil, fmt.Errorf("read conversation plan tasks: %w", err)
	}

	type numberedTask struct {
		number int
		task   ConversationPlanTask
	}
	numbered := make([]numberedTask, 0, len(entries))
	for _, entry := range entries {
		if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
			continue
		}
		idText := strings.TrimSuffix(entry.Name(), ".json")
		number, parseErr := strconv.Atoi(idText)
		if parseErr != nil || number < 1 {
			continue
		}
		if !since.IsZero() {
			info, infoErr := entry.Info()
			if infoErr != nil {
				continue
			}
			if info.ModTime().Before(since) {
				continue
			}
		}
		content, readErr := os.ReadFile(filepath.Join(dir, entry.Name()))
		if readErr != nil {
			if db.logger != nil {
				db.logger.Debug("读取 Eino 任务文件失败",
					zap.String("conversationId", conversationID),
					zap.String("file", entry.Name()),
					zap.Error(readErr))
			}
			continue
		}
		var task ConversationPlanTask
		if decodeErr := json.Unmarshal(content, &task); decodeErr != nil {
			// TaskUpdate writes files concurrently with this read. A partial read
			// is transient, so skip it and let the next poll recover.
			if db.logger != nil {
				db.logger.Debug("解析 Eino 任务文件失败",
					zap.String("conversationId", conversationID),
					zap.String("file", entry.Name()),
					zap.Error(decodeErr))
			}
			continue
		}
		if strings.TrimSpace(task.ID) == "" {
			task.ID = idText
		}
		if strings.EqualFold(strings.TrimSpace(task.Status), "deleted") {
			continue
		}
		numbered = append(numbered, numberedTask{number: number, task: task})
	}

	sort.SliceStable(numbered, func(i, j int) bool {
		return numbered[i].number < numbered[j].number
	})
	tasks := make([]ConversationPlanTask, 0, len(numbered))
	for _, item := range numbered {
		tasks = append(tasks, item.task)
	}
	return tasks, nil
}
