package document

import (
	"context"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"path/filepath"
	"ragflow/internal/dao"
	"strings"

	"ragflow/internal/common"
	"ragflow/internal/entity"
	"ragflow/internal/storage"
	"ragflow/internal/utility"
)

// UploadLocalDocuments stores each uploaded file in object storage and inserts a
// matching Document row into the dataset. It mirrors Python
// FileService.upload_document: it derives parser_id by filetype, merges the
// optional parser_config override into the dataset config, dedup-renames the
// filename, records size + xxhash content hash, and links each document into the
// file manager (a File row under the dataset folder + a file2document mapping)
// so it surfaces in the dataset's document list. Chunking/embedding happen later
// in the parse step, so nothing here touches the doc store index.
//
// Gaps vs Python (documented, not yet ported): thumbnail generation and
// read_potential_broken_pdf repair.
func (s *DocumentService) UploadLocalDocuments(ctx context.Context, kb *entity.Knowledgebase, tenantID string, files []*multipart.FileHeader, parentPath string, parserConfigOverride map[string]interface{}) ([]map[string]interface{}, []string) {
	storageImpl := storage.GetStorageFactory().GetStorage()
	if storageImpl == nil {
		return nil, []string{"storage not initialized"}
	}

	// Resolve (and create if needed) the dataset's file-manager folder up front.
	// Without the File / file2document linkage the document list (which inner-joins
	// file2document + file) would never surface the uploaded files.
	kbFolder, err := s.ensureKBFolder(ctx, kb, tenantID)
	if err != nil {
		return nil, []string{err.Error()}
	}

	// Merge parser_config override (allow-listed keys only) over the dataset config.
	merged := entity.JSONMap{}
	for k, v := range kb.ParserConfig {
		merged[k] = v
	}
	for k, v := range parserConfigOverride {
		merged[k] = v
	}

	safeParent := utility.SanitizeFilename(parentPath)

	// Don't silently disable dedupe protection: a transient lookup failure means
	// the existing-name set is unknown, so fail rather than risk duplicates.
	names, err := s.documentDAO.ListNamesByKbID(ctx, dao.DB, kb.ID)
	if err != nil {
		return nil, []string{err.Error()}
	}
	taken := map[string]bool{}
	for _, n := range names {
		taken[n] = true
	}

	var results []map[string]interface{}
	var errMsgs []string

	for _, fh := range files {
		var blob []byte
		blob, err = readFileHeaderBytes(fh)
		if err != nil {
			errMsgs = append(errMsgs, fh.Filename+": "+err.Error())
			continue
		}

		filename := uniqueUploadName(fh.Filename, taken)

		filetype := utility.FilenameType(filename)
		if filetype == utility.FileTypeOTHER {
			errMsgs = append(errMsgs, fh.Filename+": This type of file has not been supported yet!")
			continue
		}

		location := filename
		if safeParent != "" {
			location = safeParent + "/" + filename
		}
		for storageImpl.ObjExist(kb.ID, location) {
			location += "_"
		}
		if err = storageImpl.Put(kb.ID, location, blob); err != nil {
			errMsgs = append(errMsgs, fh.Filename+": "+err.Error())
			continue
		}

		doc := s.newDatasetDocument(kb, tenantID, filename, location, string(filetype), merged, "local", int64(len(blob)), blob)
		if err = s.InsertDocument(doc); err != nil {
			// Roll back the orphaned blob so a failed insert doesn't leak storage.
			_ = storageImpl.Remove(kb.ID, location)
			errMsgs = append(errMsgs, fh.Filename+": "+err.Error())
			continue
		}
		if err = s.addFileFromKB(ctx, doc, kbFolder.ID, kb.TenantID); err != nil {
			// Linkage failed: roll back the document row and blob so the partial
			// state doesn't leave an invisible (unlisted) document behind.
			err = s.rollbackAddFileFromKBError(ctx, doc, kb.ID, err)
			_ = storageImpl.Remove(kb.ID, location)
			errMsgs = append(errMsgs, fh.Filename+": "+err.Error())
			continue
		}
		// Only reserve the name once the write fully succeeds.
		taken[filename] = true
		results = append(results, docToRawMap(doc))
	}

	return results, errMsgs
}

// UploadEmptyDocument inserts a zero-byte "virtual" document into the dataset.
func (s *DocumentService) UploadEmptyDocument(ctx context.Context, kb *entity.Knowledgebase, tenantID, name string) (map[string]interface{}, common.ErrorCode, error) {
	// A transient lookup failure means the existing-name set is unknown; fail
	// rather than write blind and risk a duplicate.
	names, err := s.documentDAO.ListNamesByKbID(ctx, dao.DB, kb.ID)
	if err != nil {
		return nil, common.CodeServerError, err
	}
	for _, n := range names {
		if n == name {
			return nil, common.CodeDataError, fmt.Errorf("duplicated document name in the same dataset")
		}
	}

	kbFolder, err := s.ensureKBFolder(ctx, kb, tenantID)
	if err != nil {
		return nil, common.CodeServerError, err
	}

	doc := s.newDatasetDocument(kb, tenantID, name, "", "virtual", kb.ParserConfig, "local", 0, nil)
	if err = s.InsertDocument(doc); err != nil {
		return nil, common.CodeServerError, err
	}
	if err = s.addFileFromKB(ctx, doc, kbFolder.ID, kb.TenantID); err != nil {
		return nil, common.CodeServerError, s.rollbackAddFileFromKBError(ctx, doc, kb.ID, err)
	}
	return docToRawMap(doc), common.CodeSuccess, nil
}

// ensureKBFolder resolves (creating as needed) the per-dataset file-manager
// folder: root -> .knowledgebase -> <dataset name>. Mirrors Python
// get_root_folder + get_kb_folder + new_a_file_from_kb.
func (s *DocumentService) ensureKBFolder(ctx context.Context, kb *entity.Knowledgebase, tenantID string) (*entity.File, error) {
	root, err := s.fileDAO.GetRootFolder(ctx, dao.DB, tenantID)
	if err != nil {
		return nil, err
	}
	kbRoot, err := s.newAFileFromKB(ctx, tenantID, knowledgebaseFolderName, root.ID)
	if err != nil {
		return nil, err
	}
	return s.newAFileFromKB(ctx, kb.TenantID, kb.Name, kbRoot.ID)
}

// newAFileFromKB returns the existing folder named name under parentID, or
// creates it. Mirrors Python FileService.new_a_file_from_kb.
func (s *DocumentService) newAFileFromKB(ctx context.Context, tenantID, name, parentID string) (*entity.File, error) {
	existingFolders, err := s.fileDAO.Query(ctx, dao.DB, name, parentID, tenantID)
	if err != nil {
		return nil, err
	}
	for _, f := range existingFolders {
		if f.TenantID == tenantID {
			return f, nil
		}
	}
	loc := ""
	folder := &entity.File{
		ID:         utility.GenerateToken(),
		ParentID:   parentID,
		TenantID:   tenantID,
		CreatedBy:  tenantID,
		Name:       name,
		Type:       "folder",
		Size:       0,
		Location:   &loc,
		SourceType: string(entity.FileSourceKnowledgebase),
	}
	if err := s.fileDAO.Create(ctx, dao.DB, folder); err != nil {
		return nil, err
	}
	return folder, nil
}

// addFileFromKB links a document into the file manager: a File row under the
// dataset folder plus a file2document mapping. Mirrors Python
// FileService.add_file_from_kb (idempotent on the document mapping).
func (s *DocumentService) addFileFromKB(ctx context.Context, doc *entity.Document, kbFolderID, tenantID string) error {
	if existing, err := s.file2DocumentDAO.GetByDocumentID(ctx, dao.DB, doc.ID); err == nil && len(existing) > 0 {
		return nil
	}
	name := ""
	if doc.Name != nil {
		name = *doc.Name
	}
	loc := ""
	if doc.Location != nil {
		loc = *doc.Location
	}
	fileID := utility.GenerateToken()
	file := &entity.File{
		ID:         fileID,
		ParentID:   kbFolderID,
		TenantID:   tenantID,
		CreatedBy:  tenantID,
		Name:       name,
		Type:       doc.Type,
		Size:       doc.Size,
		Location:   &loc,
		SourceType: string(entity.FileSourceKnowledgebase),
	}
	if err := s.fileDAO.Create(ctx, dao.DB, file); err != nil {
		return err
	}
	docID := doc.ID
	if err := s.file2DocumentDAO.Create(ctx, dao.DB, &entity.File2Document{
		ID:         utility.GenerateToken(),
		FileID:     &fileID,
		DocumentID: &docID,
	}); err != nil {
		_ = s.fileDAO.Delete(ctx, dao.DB, fileID)
		return err
	}
	return nil
}

func (s *DocumentService) UploadWebDocument(ctx context.Context, kb *entity.Knowledgebase, tenantID, name, url string) (map[string]interface{}, common.ErrorCode, error) {
	storageImpl := storage.GetStorageFactory().GetStorage()
	if storageImpl == nil {
		return nil, common.CodeServerError, fmt.Errorf("storage not initialized")
	}

	kbFolder, err := s.ensureKBFolder(ctx, kb, tenantID)
	if err != nil {
		return nil, common.CodeServerError, err
	}

	names, err := s.documentDAO.ListNamesByKbID(ctx, dao.DB, kb.ID)
	if err != nil {
		return nil, common.CodeServerError, err
	}
	taken := map[string]bool{}
	for _, n := range names {
		taken[n] = true
	}

	blob, headers, _, err := utility.FetchRemoteFileSafely(ctx, url, maxUploadDocSize)
	if err != nil {
		return nil, common.CodeDataError, err
	}
	contentType := ""
	if headers != nil {
		contentType = headers.Get("Content-Type")
	}
	filename := normalizeWebDocumentName(name, contentType, blob)
	filename, _, blob = utility.NormalizeUploadInfoContent(filename, contentType, blob)
	filename = uniqueUploadName(filename, taken)

	filetype := utility.FilenameType(filename)
	if filetype == utility.FileTypeOTHER {
		return nil, common.CodeDataError, fmt.Errorf("this type of file has not been supported yet")
	}

	location := filename
	for storageImpl.ObjExist(kb.ID, location) {
		location += "_"
	}
	if err = storageImpl.Put(kb.ID, location, blob); err != nil {
		return nil, common.CodeServerError, err
	}

	doc := s.newDatasetDocument(kb, tenantID, filename, location, string(filetype), kb.ParserConfig, "web", int64(len(blob)), blob)
	if err = s.InsertDocument(doc); err != nil {
		_ = storageImpl.Remove(kb.ID, location)
		return nil, common.CodeServerError, err
	}
	if err = s.addFileFromKB(ctx, doc, kbFolder.ID, kb.TenantID); err != nil {
		err = s.rollbackAddFileFromKBError(ctx, doc, kb.ID, err)
		_ = storageImpl.Remove(kb.ID, location)
		return nil, common.CodeServerError, err
	}
	return docToRawMap(doc), common.CodeSuccess, nil
}

func normalizeWebDocumentName(name, contentType string, blob []byte) string {
	filename := utility.SanitizeFilename(name)
	if filepath.Ext(filename) != "" {
		return filename
	}
	lowerCT := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
	switch {
	case lowerCT == "application/pdf" || http.DetectContentType(blob) == "application/pdf" || utility.BytesLooksLikePDF(blob):
		return filename + ".pdf"
	case lowerCT == "text/html" || lowerCT == "application/xhtml+xml" || utility.LooksLikeHTML(blob):
		return filename + ".html"
	default:
		return filename
	}
}

// newDatasetDocument builds a Document row for an upload, deriving parser_id,
// suffix and content hash. blob may be nil for the empty/virtual document.
func (s *DocumentService) newDatasetDocument(kb *entity.Knowledgebase, tenantID, filename, location, filetype string, parserConfig entity.JSONMap, src string, size int64, blob []byte) *entity.Document {
	docID := utility.GenerateToken()
	run := "0"
	status := "1"
	suffix := ""
	if i := strings.LastIndex(filename, "."); i >= 0 {
		suffix = filename[i+1:]
	}
	parserID := kb.ParserID
	if kb.PipelineID != nil {
		parserID = "" // canvas pipeline mode — parser_id not applicable
	}
	loc := location
	doc := &entity.Document{
		ID:           docID,
		KbID:         kb.ID,
		ParserID:     parserID,
		PipelineID:   kb.PipelineID,
		ParserConfig: parserConfig,
		CreatedBy:    tenantID,
		Type:         filetype,
		SourceType:   src,
		Name:         &filename,
		Location:     &loc,
		Size:         size,
		Suffix:       suffix,
		Run:          &run,
		Status:       &status,
	}
	if blob != nil {
		hash := contentHashHex(blob)
		doc.ContentHash = &hash
	}
	return doc
}

// docToRawMap serialises a freshly created Document into the raw key shape the
// handler remaps (chunk_num→chunk_count, kb_id→dataset_id).
func docToRawMap(doc *entity.Document) map[string]interface{} {
	m := map[string]interface{}{
		"id":            doc.ID,
		"kb_id":         doc.KbID,
		"parser_id":     doc.ParserID,
		"parser_config": map[string]interface{}(doc.ParserConfig),
		"created_by":    doc.CreatedBy,
		"type":          doc.Type,
		"source_type":   doc.SourceType,
		"size":          doc.Size,
		"chunk_num":     doc.ChunkNum,
		"token_num":     doc.TokenNum,
		"suffix":        doc.Suffix,
		"run":           "0",
	}
	if doc.Name != nil {
		m["name"] = *doc.Name
	}
	if doc.Location != nil {
		m["location"] = *doc.Location
	}
	if doc.PipelineID != nil {
		m["pipeline_id"] = *doc.PipelineID
	}
	if doc.ContentHash != nil {
		m["content_hash"] = *doc.ContentHash
	}
	return m
}

// uniqueUploadName appends a numeric suffix until the name is free, mirroring
// Python duplicate_name.
func uniqueUploadName(name string, taken map[string]bool) string {
	if !taken[name] {
		return name
	}
	base, ext := name, ""
	if i := strings.LastIndex(name, "."); i >= 0 {
		base, ext = name[:i], name[i:]
	}
	for i := 1; ; i++ {
		candidate := fmt.Sprintf("%s(%d)%s", base, i, ext)
		if !taken[candidate] {
			return candidate
		}
	}
}

func readFileHeaderBytes(fh *multipart.FileHeader) ([]byte, error) {
	if fh.Size > maxUploadDocSize {
		return nil, fmt.Errorf("file exceeds the maximum allowed size of %d bytes", maxUploadDocSize)
	}
	src, err := fh.Open()
	if err != nil {
		return nil, err
	}
	defer src.Close()
	blob, err := io.ReadAll(io.LimitReader(src, maxUploadDocSize+1))
	if err != nil {
		return nil, err
	}
	if len(blob) > maxUploadDocSize {
		return nil, fmt.Errorf("file exceeds the maximum allowed size of %d bytes", maxUploadDocSize)
	}
	return blob, nil
}
