package handler

import (
	"archive/zip"
	"crypto/rand"
	"encoding/json"
	"fmt"
	"io"
	"mime"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"cyberstrike-ai/internal/audit"
	"cyberstrike-ai/internal/database"
	"cyberstrike-ai/internal/security"

	"github.com/gin-gonic/gin"
	"go.uber.org/zap"
)

const (
	chatUploadsRootDirName       = "chat_uploads"
	reductionRootDirName         = "tmp/reduction"
	workspaceRootDirName         = "tmp/workspace"
	artifactsRootDirName         = "data/conversation_artifacts"
	reductionVirtualPrefix       = "__reduction__/"
	workspaceVirtualPrefix       = "__workspace__/"
	artifactVirtualPrefix        = "__conversation_artifact__/"
	chatUploadSourceUpload       = "upload"
	chatUploadSourceReduction    = "reduction"
	chatUploadSourceWorkspace    = "workspace"
	chatUploadSourceConversation = "conversation_artifact"
	maxChatUploadEditBytes       = 2 * 1024 * 1024 // 文本编辑上限
)

// ChatUploadsHandler 对话中上传附件（chat_uploads 目录）的管理 API
type ChatUploadsHandler struct {
	logger *zap.Logger
	audit  *audit.Service
	db     *database.DB
}

// SetAudit wires platform audit logging.
func (h *ChatUploadsHandler) SetAudit(s *audit.Service) {
	h.audit = s
}

// NewChatUploadsHandler 创建处理器
func NewChatUploadsHandler(logger *zap.Logger, databases ...*database.DB) *ChatUploadsHandler {
	h := &ChatUploadsHandler{logger: logger}
	if len(databases) > 0 {
		h.db = databases[0]
	}
	return h
}

func (h *ChatUploadsHandler) pathAllowed(c *gin.Context, relativePath string) bool {
	session, ok := security.CurrentSession(c)
	if !ok || h.db == nil {
		return false
	}
	if session.Scope == database.RBACScopeAll {
		return true
	}
	rel := filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.TrimSpace(relativePath))))
	rel = strings.Trim(rel, "/")
	if conversationID, ownerUserID, found := h.db.GetChatUploadArtifact(rel); found {
		return strings.TrimSpace(ownerUserID) == session.UserID || h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID)
	}
	parts := strings.Split(strings.Trim(rel, "/"), "/")
	if len(parts) < 2 || parts[1] == "" || parts[1] == "_manual" {
		return false
	}
	return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", parts[1])
}

func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id string) bool {
	session, ok := security.CurrentSession(c)
	if !ok || h.db == nil {
		return false
	}
	if session.Scope == database.RBACScopeAll {
		return true
	}
	id = strings.TrimSpace(id)
	switch scope {
	case "conversations":
		if id == "" || id == "default" {
			return false
		}
		return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id)
	case "projects":
		if id == "" {
			return false
		}
		return h.db.UserCanAccessResource(session.UserID, session.Scope, "project", id)
	default:
		return false
	}
}

func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool {
	rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
	rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
	if rel == "projects" || rel == "conversations" {
		_, ok := security.CurrentSession(c)
		return ok
	}
	parts := strings.Split(filepath.ToSlash(rel), "/")
	if len(parts) < 2 {
		return false
	}
	return h.reductionPathAllowed(c, parts[0], parts[1])
}

func (h *ChatUploadsHandler) workspacePathAllowed(c *gin.Context, scope, id string) bool {
	return h.reductionPathAllowed(c, scope, id)
}

func (h *ChatUploadsHandler) workspaceVirtualPathAllowed(c *gin.Context, relativePath string) bool {
	rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
	rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
	if rel == "projects" || rel == "conversations" {
		_, ok := security.CurrentSession(c)
		return ok
	}
	parts := strings.Split(filepath.ToSlash(rel), "/")
	if len(parts) < 2 {
		return false
	}
	return h.workspacePathAllowed(c, parts[0], parts[1])
}

func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool {
	session, ok := security.CurrentSession(c)
	if !ok || h.db == nil {
		return false
	}
	if session.Scope == database.RBACScopeAll {
		return true
	}
	conversationID = strings.TrimSpace(conversationID)
	if conversationID == "" || conversationID == "default" {
		return false
	}
	return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID)
}

func (h *ChatUploadsHandler) conversationArtifactVirtualPathAllowed(c *gin.Context, relativePath string) bool {
	rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
	parts := strings.Split(filepath.ToSlash(rel), "/")
	if len(parts) < 1 {
		return false
	}
	return h.conversationArtifactPathAllowed(c, parts[0])
}

func (h *ChatUploadsHandler) absRoot() (string, error) {
	cwd, err := os.Getwd()
	if err != nil {
		return "", err
	}
	return filepath.Abs(filepath.Join(cwd, chatUploadsRootDirName))
}

func (h *ChatUploadsHandler) absReductionRoot() (string, error) {
	if h.db != nil {
		if base := strings.TrimSpace(h.db.EinoReductionBaseDir()); base != "" {
			if filepath.IsAbs(base) {
				return filepath.Abs(base)
			}
			cwd, err := os.Getwd()
			if err != nil {
				return "", err
			}
			return filepath.Abs(filepath.Join(cwd, base))
		}
	}
	cwd, err := os.Getwd()
	if err != nil {
		return "", err
	}
	return filepath.Abs(filepath.Join(cwd, reductionRootDirName))
}

func (h *ChatUploadsHandler) absWorkspaceRoot() (string, error) {
	if h.db != nil {
		if base := strings.TrimSpace(h.db.EinoWorkspaceBaseDir()); base != "" {
			if filepath.IsAbs(base) {
				return filepath.Abs(base)
			}
			cwd, err := os.Getwd()
			if err != nil {
				return "", err
			}
			return filepath.Abs(filepath.Join(cwd, base))
		}
	}
	cwd, err := os.Getwd()
	if err != nil {
		return "", err
	}
	return filepath.Abs(filepath.Join(cwd, workspaceRootDirName))
}

func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) {
	if h.db != nil {
		if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" {
			if filepath.IsAbs(base) {
				return filepath.Abs(base)
			}
			cwd, err := os.Getwd()
			if err != nil {
				return "", err
			}
			return filepath.Abs(filepath.Join(cwd, base))
		}
	}
	cwd, err := os.Getwd()
	if err != nil {
		return "", err
	}
	return filepath.Abs(filepath.Join(cwd, artifactsRootDirName))
}

// resolveUnderChatUploads 校验 relativePath（使用 / 分隔）对应文件必须在 chat_uploads 根下
func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs string, err error) {
	root, err := h.absRoot()
	if err != nil {
		return "", err
	}
	rel := strings.TrimSpace(relativePath)
	if rel == "" {
		return "", fmt.Errorf("empty path")
	}
	rel = filepath.Clean(filepath.FromSlash(rel))
	if rel == "." || strings.HasPrefix(rel, "..") {
		return "", fmt.Errorf("invalid path")
	}
	full := filepath.Join(root, rel)
	full, err = filepath.Abs(full)
	if err != nil {
		return "", err
	}
	rootAbs, _ := filepath.Abs(root)
	if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
		return "", fmt.Errorf("path escapes chat_uploads root")
	}
	return full, nil
}

// ChatUploadFileItem 列表项
type ChatUploadFileItem struct {
	RelativePath      string `json:"relativePath"`
	AbsolutePath      string `json:"absolutePath"` // 服务器上的绝对路径，便于在对话中引用（与附件落盘路径一致）
	Name              string `json:"name"`
	Source            string `json:"source,omitempty"`
	Size              int64  `json:"size"`
	ModifiedUnix      int64  `json:"modifiedUnix"`
	Date              string `json:"date"`
	ConversationID    string `json:"conversationId"`
	ConversationTitle string `json:"conversationTitle,omitempty"`
	ProjectID         string `json:"projectId,omitempty"`
	ProjectName       string `json:"projectName,omitempty"`
	// SubPath 为日期、会话目录之下的子路径（不含文件名），如 date/conv/a/b/file 则为 "a/b"；无嵌套则为 ""。
	SubPath string `json:"subPath"`
}

func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache map[string]string) string {
	conversationID = strings.TrimSpace(conversationID)
	if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil {
		return ""
	}
	if v, ok := cache[conversationID]; ok {
		return v
	}
	projectID, err := h.db.GetConversationProjectID(conversationID)
	if err != nil {
		projectID = ""
	}
	cache[conversationID] = projectID
	return projectID
}

func (h *ChatUploadsHandler) conversationTitle(conversationID string, cache map[string]string) string {
	conversationID = strings.TrimSpace(conversationID)
	if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil {
		return ""
	}
	if v, ok := cache[conversationID]; ok {
		return v
	}
	title, err := h.db.GetConversationTitle(conversationID)
	if err != nil {
		title = ""
	}
	cache[conversationID] = title
	return title
}

func (h *ChatUploadsHandler) projectName(projectID string, cache map[string]string) string {
	projectID = strings.TrimSpace(projectID)
	if projectID == "" || h.db == nil {
		return ""
	}
	if v, ok := cache[projectID]; ok {
		return v
	}
	name, err := h.db.GetProjectName(projectID)
	if err != nil {
		name = ""
	}
	cache[projectID] = name
	return name
}

func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) {
	root, err := h.absRoot()
	if err != nil {
		return nil, nil, err
	}
	// 保证根目录存在，否则「按文件夹」浏览时无法 mkdir，且首次列表为空时界面无路径工具栏
	if err := os.MkdirAll(root, 0755); err != nil {
		return nil, nil, err
	}
	var files []ChatUploadFileItem
	var folders []string
	projectCache := make(map[string]string)
	projectNameCache := make(map[string]string)
	conversationTitleCache := make(map[string]string)
	err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		rel, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		if rel == "." {
			return nil
		}
		relSlash := filepath.ToSlash(rel)
		if d.IsDir() {
			folders = append(folders, relSlash)
			return nil
		}
		info, err := d.Info()
		if err != nil {
			return err
		}
		parts := strings.Split(relSlash, "/")
		var dateStr, convID string
		if len(parts) >= 2 {
			dateStr = parts[0]
		}
		if len(parts) >= 3 {
			convID = parts[1]
		}
		projectID := h.conversationProjectID(convID, projectCache)
		var subPath string
		if len(parts) >= 4 {
			subPath = strings.Join(parts[2:len(parts)-1], "/")
		}
		if conversationFilter != "" && convID != conversationFilter {
			return nil
		}
		if projectFilter != "" && projectID != projectFilter {
			return nil
		}
		absPath, _ := filepath.Abs(path)
		files = append(files, ChatUploadFileItem{
			RelativePath:      relSlash,
			AbsolutePath:      absPath,
			Name:              d.Name(),
			Source:            chatUploadSourceUpload,
			Size:              info.Size(),
			ModifiedUnix:      info.ModTime().Unix(),
			Date:              dateStr,
			ConversationID:    convID,
			ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
			ProjectID:         projectID,
			ProjectName:       h.projectName(projectID, projectNameCache),
			SubPath:           subPath,
		})
		return nil
	})
	if err != nil {
		return nil, nil, err
	}
	if conversationFilter != "" || projectFilter != "" {
		filteredFolders := make([]string, 0, len(folders))
		for _, rel := range folders {
			parts := strings.Split(rel, "/")
			if len(parts) >= 2 && (conversationFilter == "" || parts[1] == conversationFilter) && (projectFilter == "" || h.conversationProjectID(parts[1], projectCache) == projectFilter) {
				filteredFolders = append(filteredFolders, rel)
				continue
			}
			if len(parts) == 1 {
				prefix := rel + "/"
				for _, f := range files {
					if strings.HasPrefix(f.RelativePath, prefix) {
						filteredFolders = append(filteredFolders, rel)
						break
					}
				}
			}
		}
		folders = filteredFolders
	}
	files = filterSlice(files, func(file ChatUploadFileItem) bool {
		return h.pathAllowed(c, file.RelativePath)
	})
	folders = filterSlice(folders, func(folder string) bool {
		if h.pathAllowed(c, folder) {
			return true
		}
		prefix := strings.TrimSuffix(folder, "/") + "/"
		for _, file := range files {
			if strings.HasPrefix(file.RelativePath, prefix) {
				return true
			}
		}
		return false
	})
	sort.Strings(folders)
	sort.Slice(files, func(i, j int) bool {
		return files[i].ModifiedUnix > files[j].ModifiedUnix
	})
	reductionFiles, err := h.collectReductionFiles(c, conversationFilter, projectFilter)
	if err != nil {
		h.logger.Warn("列举 reduction 产物失败", zap.Error(err))
	} else if len(reductionFiles) > 0 {
		files = append(files, reductionFiles...)
	}
	workspaceFiles, err := h.collectWorkspaceFiles(c, conversationFilter, projectFilter)
	if err != nil {
		h.logger.Warn("列举 workspace 产物失败", zap.Error(err))
	} else if len(workspaceFiles) > 0 {
		files = append(files, workspaceFiles...)
	}
	artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter)
	if err != nil {
		h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err))
	} else if len(artifactFiles) > 0 {
		files = append(files, artifactFiles...)
	}
	sort.Slice(files, func(i, j int) bool {
		return files[i].ModifiedUnix > files[j].ModifiedUnix
	})
	return files, folders, nil
}

func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
	root, err := h.absReductionRoot()
	if err != nil {
		return nil, err
	}
	if st, err := os.Stat(root); err != nil || !st.IsDir() {
		return nil, nil
	}
	projectCache := make(map[string]string)
	projectNameCache := make(map[string]string)
	conversationTitleCache := make(map[string]string)
	files := make([]ChatUploadFileItem, 0)
	for _, scope := range []string{"conversations", "projects"} {
		scopeRoot := filepath.Join(root, scope)
		_ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error {
			if walkErr != nil || d == nil || d.IsDir() {
				return nil
			}
			rel, err := filepath.Rel(root, path)
			if err != nil {
				return nil
			}
			relSlash := filepath.ToSlash(rel)
			parts := strings.Split(relSlash, "/")
			if len(parts) < 3 {
				return nil
			}
			ownerID := parts[1]
			if !h.reductionPathAllowed(c, scope, ownerID) {
				return nil
			}
			var convID, projectID string
			if scope == "conversations" {
				convID = ownerID
				projectID = h.conversationProjectID(convID, projectCache)
			} else {
				projectID = ownerID
			}
			if conversationFilter != "" && convID != conversationFilter {
				if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
					return nil
				}
				convID = conversationFilter
			}
			if projectFilter != "" && projectID != projectFilter {
				return nil
			}
			info, err := d.Info()
			if err != nil {
				return nil
			}
			name := d.Name()
			if filepath.Ext(name) == "" {
				name += ".txt"
			}
			abs, _ := filepath.Abs(path)
			files = append(files, ChatUploadFileItem{
				RelativePath:      reductionVirtualPrefix + relSlash,
				AbsolutePath:      abs,
				Name:              name,
				Source:            chatUploadSourceReduction,
				Size:              info.Size(),
				ModifiedUnix:      info.ModTime().Unix(),
				Date:              info.ModTime().Format("2006-01-02"),
				ConversationID:    convID,
				ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
				ProjectID:         projectID,
				ProjectName:       h.projectName(projectID, projectNameCache),
				SubPath:           strings.Join(parts[2:len(parts)-1], "/"),
			})
			return nil
		})
	}
	return files, nil
}

func (h *ChatUploadsHandler) collectWorkspaceFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
	root, err := h.absWorkspaceRoot()
	if err != nil {
		return nil, err
	}
	if st, err := os.Stat(root); err != nil || !st.IsDir() {
		return nil, nil
	}
	projectCache := make(map[string]string)
	projectNameCache := make(map[string]string)
	conversationTitleCache := make(map[string]string)
	files := make([]ChatUploadFileItem, 0)
	for _, scope := range []string{"conversations", "projects"} {
		scopeRoot := filepath.Join(root, scope)
		_ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error {
			if walkErr != nil || d == nil || d.IsDir() {
				return nil
			}
			rel, err := filepath.Rel(root, path)
			if err != nil {
				return nil
			}
			relSlash := filepath.ToSlash(rel)
			parts := strings.Split(relSlash, "/")
			if len(parts) < 3 {
				return nil
			}
			ownerID := parts[1]
			if !h.workspacePathAllowed(c, scope, ownerID) {
				return nil
			}
			var convID, projectID string
			if scope == "conversations" {
				convID = ownerID
				projectID = h.conversationProjectID(convID, projectCache)
			} else {
				projectID = ownerID
			}
			if conversationFilter != "" && convID != conversationFilter {
				if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
					return nil
				}
				convID = conversationFilter
			}
			if projectFilter != "" && projectID != projectFilter {
				return nil
			}
			info, err := d.Info()
			if err != nil {
				return nil
			}
			abs, _ := filepath.Abs(path)
			files = append(files, ChatUploadFileItem{
				RelativePath:      workspaceVirtualPrefix + relSlash,
				AbsolutePath:      abs,
				Name:              d.Name(),
				Source:            chatUploadSourceWorkspace,
				Size:              info.Size(),
				ModifiedUnix:      info.ModTime().Unix(),
				Date:              info.ModTime().Format("2006-01-02"),
				ConversationID:    convID,
				ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
				ProjectID:         projectID,
				ProjectName:       h.projectName(projectID, projectNameCache),
				SubPath:           strings.Join(parts[2:len(parts)-1], "/"),
			})
			return nil
		})
	}
	return files, nil
}

func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
	root, err := h.absConversationArtifactsRoot()
	if err != nil {
		return nil, err
	}
	if st, err := os.Stat(root); err != nil || !st.IsDir() {
		return nil, nil
	}
	projectCache := make(map[string]string)
	projectNameCache := make(map[string]string)
	conversationTitleCache := make(map[string]string)
	files := make([]ChatUploadFileItem, 0)
	_ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
		if walkErr != nil || d == nil || d.IsDir() {
			return nil
		}
		rel, err := filepath.Rel(root, path)
		if err != nil {
			return nil
		}
		relSlash := filepath.ToSlash(rel)
		parts := strings.Split(relSlash, "/")
		if len(parts) < 2 {
			return nil
		}
		convID := parts[0]
		if !h.conversationArtifactPathAllowed(c, convID) {
			return nil
		}
		projectID := h.conversationProjectID(convID, projectCache)
		if conversationFilter != "" && convID != conversationFilter {
			return nil
		}
		if projectFilter != "" && projectID != projectFilter {
			return nil
		}
		info, err := d.Info()
		if err != nil {
			return nil
		}
		name := d.Name()
		if filepath.Ext(name) == "" {
			name += ".txt"
		}
		abs, _ := filepath.Abs(path)
		files = append(files, ChatUploadFileItem{
			RelativePath:      artifactVirtualPrefix + relSlash,
			AbsolutePath:      abs,
			Name:              name,
			Source:            chatUploadSourceConversation,
			Size:              info.Size(),
			ModifiedUnix:      info.ModTime().Unix(),
			Date:              info.ModTime().Format("2006-01-02"),
			ConversationID:    convID,
			ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
			ProjectID:         projectID,
			ProjectName:       h.projectName(projectID, projectNameCache),
			SubPath:           strings.Join(parts[1:len(parts)-1], "/"),
		})
		return nil
	})
	return files, nil
}

func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (string, error) {
	rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
	rel = filepath.Clean(filepath.FromSlash(rel))
	if rel == "." || strings.HasPrefix(rel, "..") {
		return "", fmt.Errorf("invalid path")
	}
	root, err := h.absReductionRoot()
	if err != nil {
		return "", err
	}
	full, err := filepath.Abs(filepath.Join(root, rel))
	if err != nil {
		return "", err
	}
	rootAbs, _ := filepath.Abs(root)
	if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
		return "", fmt.Errorf("path escapes reduction root")
	}
	return full, nil
}

func (h *ChatUploadsHandler) resolveWorkspaceVirtualPath(relativePath string) (string, error) {
	rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
	rel = filepath.Clean(filepath.FromSlash(rel))
	if rel == "." || strings.HasPrefix(rel, "..") {
		return "", fmt.Errorf("invalid path")
	}
	root, err := h.absWorkspaceRoot()
	if err != nil {
		return "", err
	}
	full, err := filepath.Abs(filepath.Join(root, rel))
	if err != nil {
		return "", err
	}
	rootAbs, _ := filepath.Abs(root)
	if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
		return "", fmt.Errorf("path escapes workspace root")
	}
	return full, nil
}

func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) {
	rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
	rel = filepath.Clean(filepath.FromSlash(rel))
	if rel == "." || strings.HasPrefix(rel, "..") {
		return "", fmt.Errorf("invalid path")
	}
	root, err := h.absConversationArtifactsRoot()
	if err != nil {
		return "", err
	}
	full, err := filepath.Abs(filepath.Join(root, rel))
	if err != nil {
		return "", err
	}
	rootAbs, _ := filepath.Abs(root)
	if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
		return "", fmt.Errorf("path escapes conversation artifacts root")
	}
	return full, nil
}

func chatUploadItemIsInternal(item ChatUploadFileItem) bool {
	return item.Source == chatUploadSourceReduction ||
		item.Source == chatUploadSourceWorkspace ||
		item.Source == chatUploadSourceConversation ||
		strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) ||
		strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) ||
		strings.HasPrefix(item.RelativePath, artifactVirtualPrefix)
}

func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (string, error) {
	switch {
	case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix):
		return h.resolveReductionVirtualPath(item.RelativePath)
	case item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix):
		return h.resolveWorkspaceVirtualPath(item.RelativePath)
	case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix):
		return h.resolveConversationArtifactVirtualPath(item.RelativePath)
	default:
		return h.resolveUnderChatUploads(item.RelativePath)
	}
}

func chatUploadSourceMatches(item ChatUploadFileItem, sourceFilter string) bool {
	sourceFilter = strings.TrimSpace(sourceFilter)
	if sourceFilter == "" || sourceFilter == "all" {
		return true
	}
	source := strings.TrimSpace(item.Source)
	if source == "" {
		source = chatUploadSourceUpload
	}
	return source == sourceFilter
}

func chatUploadSearchMatches(item ChatUploadFileItem, search string) bool {
	search = strings.ToLower(strings.TrimSpace(search))
	if search == "" {
		return true
	}
	values := []string{
		item.RelativePath,
		item.Name,
		item.Source,
		item.Date,
		item.ConversationID,
		item.ProjectID,
		item.SubPath,
	}
	for _, value := range values {
		if strings.Contains(strings.ToLower(value), search) {
			return true
		}
	}
	return false
}

func filterChatUploadItems(files []ChatUploadFileItem, sourceFilter, search string) []ChatUploadFileItem {
	if strings.TrimSpace(sourceFilter) == "" && strings.TrimSpace(search) == "" {
		return files
	}
	out := make([]ChatUploadFileItem, 0, len(files))
	for _, item := range files {
		if chatUploadSourceMatches(item, sourceFilter) && chatUploadSearchMatches(item, search) {
			out = append(out, item)
		}
	}
	return out
}

func parsePositiveIntQuery(c *gin.Context, key string, def, max int) int {
	raw := strings.TrimSpace(c.Query(key))
	if raw == "" {
		return def
	}
	if strings.EqualFold(raw, "all") {
		return 0
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n <= 0 {
		return def
	}
	if max > 0 && n > max {
		return max
	}
	return n
}

func paginateChatUploadItems(files []ChatUploadFileItem, page, pageSize int) []ChatUploadFileItem {
	if pageSize <= 0 {
		return files
	}
	if page <= 0 {
		page = 1
	}
	start := (page - 1) * pageSize
	if start >= len(files) {
		return []ChatUploadFileItem{}
	}
	end := start + pageSize
	if end > len(files) {
		end = len(files)
	}
	return files[start:end]
}

// List GET /api/chat-uploads
func (h *ChatUploadsHandler) List(c *gin.Context) {
	conversationFilter := strings.TrimSpace(c.Query("conversation"))
	projectFilter := strings.TrimSpace(c.Query("project"))
	sourceFilter := strings.TrimSpace(c.Query("source"))
	search := strings.TrimSpace(c.Query("search"))
	page := parsePositiveIntQuery(c, "page", 1, 0)
	pageSize := parsePositiveIntQuery(c, "pageSize", 20, 200)
	files, folders, err := h.collectFiles(c, conversationFilter, projectFilter)
	if err != nil {
		h.logger.Warn("列举对话附件失败", zap.Error(err))
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	files = filterChatUploadItems(files, sourceFilter, search)
	total := len(files)
	paged := paginateChatUploadItems(files, page, pageSize)
	c.JSON(http.StatusOK, gin.H{
		"files":    paged,
		"folders":  folders,
		"total":    total,
		"page":     page,
		"pageSize": pageSize,
	})
}

// Export GET /api/chat-uploads/export?conversation=...&project=...
func (h *ChatUploadsHandler) Export(c *gin.Context) {
	conversationFilter := strings.TrimSpace(c.Query("conversation"))
	projectFilter := strings.TrimSpace(c.Query("project"))
	sourceFilter := strings.TrimSpace(c.Query("source"))
	search := strings.TrimSpace(c.Query("search"))
	files, _, err := h.collectFiles(c, conversationFilter, projectFilter)
	if err != nil {
		h.logger.Warn("导出对话附件失败", zap.Error(err))
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	files = filterChatUploadItems(files, sourceFilter, search)
	if len(files) == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "no files to export"})
		return
	}
	nameParts := []string{"chat-files"}
	if projectFilter != "" {
		nameParts = append(nameParts, "project-"+projectFilter)
	}
	if conversationFilter != "" {
		nameParts = append(nameParts, "conversation-"+conversationFilter)
	}
	nameParts = append(nameParts, time.Now().Format("20060102-150405"))
	filename := strings.Join(nameParts, "-") + ".zip"
	c.Header("Content-Type", "application/zip")
	c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))

	zw := zip.NewWriter(c.Writer)
	defer zw.Close()

	manifest := gin.H{
		"exportedAt":      time.Now().UTC().Format(time.RFC3339),
		"conversationId":  conversationFilter,
		"projectId":       projectFilter,
		"source":          sourceFilter,
		"search":          search,
		"fileCount":       len(files),
		"files":           files,
		"layout":          "chat uploads are stored under conversations/<conversationId>/; internal outputs are stored under internal/<source>/",
		"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, workspaceRootDirName, artifactsRootDirName},
	}
	manifestBytes, _ := json.MarshalIndent(manifest, "", "  ")
	mw, err := zw.Create("manifest.json")
	if err != nil {
		h.logger.Warn("写入附件导出清单失败", zap.Error(err))
		return
	}
	_, _ = mw.Write(manifestBytes)

	used := make(map[string]int)
	for _, item := range files {
		abs, err := h.resolveListedFilePath(item)
		if err != nil {
			continue
		}
		st, err := os.Stat(abs)
		if err != nil || st.IsDir() {
			continue
		}
		var zipName string
		if item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) {
			rel := strings.TrimPrefix(item.RelativePath, reductionVirtualPrefix)
			zipName = filepath.ToSlash(filepath.Join("internal", "reduction", rel))
			if filepath.Ext(zipName) == "" {
				zipName += ".txt"
			}
		} else if item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) {
			rel := strings.TrimPrefix(item.RelativePath, workspaceVirtualPrefix)
			zipName = filepath.ToSlash(filepath.Join("internal", "workspace", rel))
		} else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) {
			rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix)
			zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel))
			if filepath.Ext(zipName) == "" {
				zipName += ".txt"
			}
		} else {
			conv := strings.TrimSpace(item.ConversationID)
			if conv == "" || conv == "_manual" || conv == "_new" {
				conv = "manual"
			}
			zipName = filepath.ToSlash(filepath.Join("conversations", conv, strings.TrimSpace(item.SubPath), item.Name))
		}
		if used[zipName] > 0 {
			ext := filepath.Ext(zipName)
			zipName = strings.TrimSuffix(zipName, ext) + fmt.Sprintf("-%d", used[zipName]+1) + ext
		}
		used[zipName]++
		fw, err := zw.Create(zipName)
		if err != nil {
			h.logger.Warn("创建附件导出项失败", zap.String("path", item.RelativePath), zap.Error(err))
			continue
		}
		src, err := os.Open(abs)
		if err != nil {
			continue
		}
		_, copyErr := io.Copy(fw, src)
		_ = src.Close()
		if copyErr != nil {
			h.logger.Warn("复制附件导出项失败", zap.String("path", item.RelativePath), zap.Error(copyErr))
			return
		}
	}
	if h.audit != nil {
		h.audit.RecordOK(c, "file", "export", "导出对话附件", "chat_upload", filename, map[string]interface{}{
			"conversation_id": conversationFilter,
			"project_id":      projectFilter,
			"file_count":      len(files),
		})
	}
}

// Download GET /api/chat-uploads/download?path=...
func (h *ChatUploadsHandler) Download(c *gin.Context) {
	p := c.Query("path")
	if strings.HasPrefix(strings.TrimSpace(p), reductionVirtualPrefix) {
		if !h.reductionVirtualPathAllowed(c, p) {
			c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
			return
		}
		abs, err := h.resolveReductionVirtualPath(p)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		st, err := os.Stat(abs)
		if err != nil || st.IsDir() {
			c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
			return
		}
		name := filepath.Base(abs)
		if filepath.Ext(name) == "" {
			name += ".txt"
		}
		c.FileAttachment(abs, name)
		return
	}
	if strings.HasPrefix(strings.TrimSpace(p), workspaceVirtualPrefix) {
		if !h.workspaceVirtualPathAllowed(c, p) {
			c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
			return
		}
		abs, err := h.resolveWorkspaceVirtualPath(p)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		st, err := os.Stat(abs)
		if err != nil || st.IsDir() {
			c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
			return
		}
		c.FileAttachment(abs, filepath.Base(abs))
		return
	}
	if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) {
		if !h.conversationArtifactVirtualPathAllowed(c, p) {
			c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
			return
		}
		abs, err := h.resolveConversationArtifactVirtualPath(p)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		st, err := os.Stat(abs)
		if err != nil || st.IsDir() {
			c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
			return
		}
		name := filepath.Base(abs)
		if filepath.Ext(name) == "" {
			name += ".txt"
		}
		c.FileAttachment(abs, name)
		return
	}
	if !h.pathAllowed(c, p) {
		c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
		return
	}
	abs, err := h.resolveUnderChatUploads(p)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	st, err := os.Stat(abs)
	if err != nil || st.IsDir() {
		c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
		return
	}
	c.FileAttachment(abs, filepath.Base(abs))
}

// ResolvePath GET /api/chat-uploads/path?path=...&kind=file|directory
func (h *ChatUploadsHandler) ResolvePath(c *gin.Context) {
	p := strings.TrimSpace(c.Query("path"))
	kind := strings.TrimSpace(c.Query("kind"))
	if kind == "" {
		kind = "file"
	}
	var abs string
	var err error
	switch {
	case strings.HasPrefix(p, reductionVirtualPrefix):
		if strings.Trim(strings.TrimPrefix(p, reductionVirtualPrefix), "/") == "" {
			if _, ok := security.CurrentSession(c); !ok {
				c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
				return
			}
			abs, err = h.absReductionRoot()
			break
		}
		if !h.reductionVirtualPathAllowed(c, p) {
			c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
			return
		}
		abs, err = h.resolveReductionVirtualPath(p)
	case strings.HasPrefix(p, workspaceVirtualPrefix):
		if strings.Trim(strings.TrimPrefix(p, workspaceVirtualPrefix), "/") == "" {
			if _, ok := security.CurrentSession(c); !ok {
				c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
				return
			}
			abs, err = h.absWorkspaceRoot()
			break
		}
		if !h.workspaceVirtualPathAllowed(c, p) {
			c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
			return
		}
		abs, err = h.resolveWorkspaceVirtualPath(p)
	case strings.HasPrefix(p, artifactVirtualPrefix):
		if strings.Trim(strings.TrimPrefix(p, artifactVirtualPrefix), "/") == "" {
			if _, ok := security.CurrentSession(c); !ok {
				c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
				return
			}
			abs, err = h.absConversationArtifactsRoot()
			break
		}
		if !h.conversationArtifactVirtualPathAllowed(c, p) {
			c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
			return
		}
		abs, err = h.resolveConversationArtifactVirtualPath(p)
	default:
		if p == "" || p == "." {
			if _, ok := security.CurrentSession(c); !ok {
				c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
				return
			}
			abs, err = h.absRoot()
			break
		}
		if !h.pathAllowed(c, p) {
			c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
			return
		}
		abs, err = h.resolveUnderChatUploads(p)
	}
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	st, err := os.Stat(abs)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "path not found"})
		return
	}
	if kind == "directory" && !st.IsDir() {
		c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
		return
	}
	if kind != "directory" && st.IsDir() {
		c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"absolutePath": abs, "isDir": st.IsDir()})
}

type chatUploadPathBody struct {
	Path string `json:"path"`
}

// Delete DELETE /api/chat-uploads
func (h *ChatUploadsHandler) Delete(c *gin.Context) {
	var body chatUploadPathBody
	if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Path) == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
		return
	}
	if !h.pathAllowed(c, body.Path) {
		c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
		return
	}
	abs, err := h.resolveUnderChatUploads(body.Path)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	st, err := os.Stat(abs)
	if err != nil {
		if os.IsNotExist(err) {
			c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if st.IsDir() {
		if err := os.RemoveAll(abs); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
	} else {
		if err := os.Remove(abs); err != nil {
			if os.IsNotExist(err) {
				c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
				return
			}
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
	}
	_ = h.db.DeleteChatUploadArtifactPath(filepath.ToSlash(filepath.Clean(filepath.FromSlash(body.Path))))
	if h.audit != nil {
		h.audit.RecordOK(c, "file", "delete", "删除对话附件", "chat_upload", body.Path, nil)
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

type chatUploadMkdirBody struct {
	Parent string `json:"parent"`
	Name   string `json:"name"`
}

// Mkdir POST /api/chat-uploads/mkdir — 在 parent 目录下新建子目录（parent 为 chat_uploads 下相对路径，空表示根目录；name 为单段目录名）
func (h *ChatUploadsHandler) Mkdir(c *gin.Context) {
	var body chatUploadMkdirBody
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
		return
	}
	name := strings.TrimSpace(body.Name)
	if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
		return
	}
	if utf8.RuneCountInString(name) > 200 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"})
		return
	}

	parent := strings.TrimSpace(body.Parent)
	parent = filepath.ToSlash(filepath.Clean(filepath.FromSlash(parent)))
	parent = strings.Trim(parent, "/")
	if parent == "." {
		parent = ""
	}
	if !h.pathAllowed(c, parent) {
		c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
		return
	}

	root, err := h.absRoot()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	if parent != "" {
		absParent, err := h.resolveUnderChatUploads(parent)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		st, err := os.Stat(absParent)
		if err != nil || !st.IsDir() {
			c.JSON(http.StatusBadRequest, gin.H{"error": "parent not found"})
			return
		}
	}

	var rel string
	if parent == "" {
		rel = name
	} else {
		rel = parent + "/" + name
	}
	absNew, err := h.resolveUnderChatUploads(rel)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if _, err := os.Stat(absNew); err == nil {
		c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
		return
	}
	if err := os.Mkdir(absNew, 0755); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	relOut, _ := filepath.Rel(root, absNew)
	c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(relOut)})
}

type chatUploadRenameBody struct {
	Path    string `json:"path"`
	NewName string `json:"newName"`
}

// Rename PUT /api/chat-uploads/rename
func (h *ChatUploadsHandler) Rename(c *gin.Context) {
	var body chatUploadRenameBody
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
		return
	}
	if !h.pathAllowed(c, body.Path) {
		c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
		return
	}
	newName := strings.TrimSpace(body.NewName)
	if newName == "" || strings.ContainsAny(newName, `/\`) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid newName"})
		return
	}
	abs, err := h.resolveUnderChatUploads(body.Path)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	dir := filepath.Dir(abs)
	newAbs := filepath.Join(dir, filepath.Base(newName))
	root, _ := h.absRoot()
	newAbs, _ = filepath.Abs(newAbs)
	if newAbs != root && !strings.HasPrefix(newAbs, root+string(filepath.Separator)) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target path"})
		return
	}
	if err := os.Rename(abs, newAbs); err != nil {
		if os.IsNotExist(err) {
			c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	newRel, _ := filepath.Rel(root, newAbs)
	oldRel := filepath.ToSlash(filepath.Clean(filepath.FromSlash(body.Path)))
	_ = h.db.RenameChatUploadArtifactPath(oldRel, filepath.ToSlash(newRel))
	c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(newRel)})
}

type chatUploadContentBody struct {
	Path    string `json:"path"`
	Content string `json:"content"`
}

// GetContent GET /api/chat-uploads/content?path=...
func (h *ChatUploadsHandler) GetContent(c *gin.Context) {
	p := c.Query("path")
	if !h.pathAllowed(c, p) {
		c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
		return
	}
	abs, err := h.resolveUnderChatUploads(p)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	st, err := os.Stat(abs)
	if err != nil || st.IsDir() {
		c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
		return
	}
	if st.Size() > maxChatUploadEditBytes {
		c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large for editor"})
		return
	}
	b, err := os.ReadFile(abs)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if !utf8.Valid(b) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "binary file not editable in UI"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"content": string(b)})
}

// PutContent PUT /api/chat-uploads/content
func (h *ChatUploadsHandler) PutContent(c *gin.Context) {
	var body chatUploadContentBody
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
		return
	}
	if !h.pathAllowed(c, body.Path) {
		c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
		return
	}
	if !utf8.ValidString(body.Content) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "content must be valid UTF-8"})
		return
	}
	if len(body.Content) > maxChatUploadEditBytes {
		c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "content too large"})
		return
	}
	abs, err := h.resolveUnderChatUploads(body.Path)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if err := os.WriteFile(abs, []byte(body.Content), 0644); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

func chatUploadShortRand(n int) string {
	const letters = "0123456789abcdef"
	b := make([]byte, n)
	_, _ = rand.Read(b)
	for i := range b {
		b[i] = letters[int(b[i])%len(letters)]
	}
	return string(b)
}

// Upload POST /api/chat-uploads multipart: file；conversationId 可选；relativeDir 可选（chat_uploads 下目录的相对路径，将文件直接上传至该目录）
func (h *ChatUploadsHandler) Upload(c *gin.Context) {
	fh, err := c.FormFile("file")
	if err != nil || fh == nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
		return
	}
	root, err := h.absRoot()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	var targetDir string
	targetRel := strings.TrimSpace(c.PostForm("relativeDir"))
	if targetRel != "" {
		if !h.pathAllowed(c, targetRel) {
			c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
			return
		}
		absDir, err := h.resolveUnderChatUploads(targetRel)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		st, err := os.Stat(absDir)
		if err != nil {
			if os.IsNotExist(err) {
				if err := os.MkdirAll(absDir, 0755); err != nil {
					c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
					return
				}
			} else {
				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
				return
			}
		} else if !st.IsDir() {
			c.JSON(http.StatusBadRequest, gin.H{"error": "relativeDir is not a directory"})
			return
		}
		targetDir = absDir
	} else {
		convID := strings.TrimSpace(c.PostForm("conversationId"))
		dateStr := time.Now().Format("2006-01-02")
		if !h.pathAllowed(c, filepath.ToSlash(filepath.Join(dateStr, convID))) {
			c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
			return
		}
		convDir := convID
		if convDir == "" {
			convDir = "_manual"
		} else {
			convDir = strings.ReplaceAll(convDir, string(filepath.Separator), "_")
		}
		targetDir = filepath.Join(root, dateStr, convDir)
		if err := os.MkdirAll(targetDir, 0755); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
	}
	baseName := filepath.Base(fh.Filename)
	if baseName == "" || baseName == "." {
		baseName = "file"
	}
	baseName = strings.ReplaceAll(baseName, string(filepath.Separator), "_")
	ext := filepath.Ext(baseName)
	nameNoExt := strings.TrimSuffix(baseName, ext)
	suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), chatUploadShortRand(6))
	var unique string
	if ext != "" {
		unique = nameNoExt + suffix + ext
	} else {
		unique = baseName + suffix
	}
	fullPath := filepath.Join(targetDir, unique)
	src, err := fh.Open()
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	defer src.Close()
	dst, err := os.Create(fullPath)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	defer dst.Close()
	if _, err := io.Copy(dst, src); err != nil {
		_ = os.Remove(fullPath)
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	rel, _ := filepath.Rel(root, fullPath)
	absSaved, _ := filepath.Abs(fullPath)
	if session, ok := security.CurrentSession(c); ok {
		conversationID := strings.TrimSpace(c.PostForm("conversationId"))
		if conversationID == "" {
			parts := strings.Split(filepath.ToSlash(rel), "/")
			if len(parts) >= 2 {
				conversationID = parts[1]
			}
		}
		_ = h.db.UpsertChatUploadArtifact(filepath.ToSlash(rel), conversationID, session.UserID)
	}
	if h.audit != nil {
		h.audit.RecordOK(c, "file", "upload", "上传对话附件", "chat_upload", filepath.ToSlash(rel), map[string]interface{}{
			"name": unique,
		})
	}
	c.JSON(http.StatusOK, gin.H{
		"ok":           true,
		"relativePath": filepath.ToSlash(rel),
		"absolutePath": absSaved,
		"name":         unique,
	})
}
