package task

import (
	"archive/zip"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"ragflow/internal/server/config"
	"sort"
	"strings"
	"testing"
	"time"

	"ragflow/internal/common"
	"ragflow/internal/dao"
	"ragflow/internal/entity"
	_ "ragflow/internal/ingestion/component"
	_ "ragflow/internal/ingestion/component/chunker"
	pipelinepkg "ragflow/internal/ingestion/pipeline"
	"ragflow/internal/server"
	"ragflow/internal/storage"
	"ragflow/internal/tokenizer"

	"github.com/glebarez/sqlite"

	"go.uber.org/zap"
	"gorm.io/gorm"
	gormlogger "gorm.io/gorm/logger"
)

func TestPipelineExecutor_Run_RealCanvasDSL_UsesGeneralPipeline(t *testing.T) {
	requireTokenizerPool(t)

	mustLoadTaskTestConfig(t)
	origDB := dao.DB
	realDB := mustOpenTaskTestDB(t)
	dao.DB = realDB
	t.Cleanup(func() {
		dao.DB = origDB
	})

	realStorage := storage.NewMemoryStorage()
	origStorage := storage.GetStorageFactory().GetStorage()
	storage.GetStorageFactory().SetStorage(realStorage)
	t.Cleanup(func() {
		storage.GetStorageFactory().SetStorage(origStorage)
	})

	templatePath := filepath.Join(taskRepoRoot(t), "internal", "ingestion", "pipeline", "template", "ingestion_pipeline_general.json")
	templateBytes, err := os.ReadFile(templatePath)
	if err != nil {
		t.Fatalf("read template: %v", err)
	}
	templateBytes = disableTokenizerEmbeddingForTaskTemplate(t, templateBytes)
	var templateDSL entity.JSONMap
	if err := json.Unmarshal(templateBytes, &templateDSL); err != nil {
		t.Fatalf("unmarshal template dsl: %v", err)
	}

	suffix := fmt.Sprintf("%d", time.Now().UnixNano())
	tenantID := taskLimit32("it_tenant_" + suffix)
	kbID := taskLimit32("it_kb_" + suffix)
	docID := taskLimit32("it_doc_" + suffix)
	fileID := taskLimit32("it_file_" + suffix)
	canvasID := taskLimit32("it_canvas_" + suffix)
	bucket := taskS3SafeBucketName(kbID)
	objectPath := fmt.Sprintf("integration/task/%s/template-general.txt", docID)
	docName := "template-general.txt"
	content := "Alpha paragraph\n\nBeta paragraph."

	mustSeedTaskRealPipelineDocument(t, realDB, realStorage, tenantID, kbID, docID, fileID, bucket, objectPath, docName, content)
	if err := realDB.Model(&entity.Document{}).Where("id = ?", docID).Update("pipeline_id", canvasID).Error; err != nil {
		t.Fatalf("set document pipeline_id: %v", err)
	}
	if err := realDB.Create(&entity.UserCanvas{
		ID:             canvasID,
		UserID:         tenantID,
		Permission:     "me",
		CanvasCategory: "agent_canvas",
		DSL:            templateDSL,
	}).Error; err != nil {
		t.Fatalf("create user canvas: %v", err)
	}
	t.Cleanup(func() {
		_ = realDB.Where("id = ?", canvasID).Delete(&entity.UserCanvas{}).Error
		cleanupTaskRealPipelineDocument(realDB, realStorage, tenantID, kbID, docID, fileID, bucket, objectPath)
	})

	taskCtx := &TaskContext{
		IngestionTask: &entity.IngestionTask{
			ID:         "task-real-canvas-1",
			DocumentID: docID,
			DatasetID:  kbID,
		},
		Doc: entity.Document{
			ID:         docID,
			KbID:       kbID,
			Name:       taskStrPtr(docName),
			PipelineID: taskStrPtr(canvasID),
		},
		KB: entity.Knowledgebase{
			ID:       kbID,
			TenantID: tenantID,
			EmbdID:   "embd-1",
		},
		Tenant: entity.Tenant{ID: tenantID},
	}

	var inserted [][]map[string]any
	svc := mustNewPipelineExecutor(t, taskCtx, canvasID, 0).
		WithInsertFunc(func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
			inserted = append(inserted, deepCopyTaskChunks(chunks))
			return nil, nil
		})

	if _, err := svc.Execute(context.Background()); err != nil {
		t.Fatalf("Run: %v", err)
	}
	if len(inserted) != 1 {
		t.Fatalf("insert calls = %d, want 1", len(inserted))
	}
	if len(inserted[0]) != 2 {
		t.Fatalf("inserted chunk count = %d, want 2", len(inserted[0]))
	}
	for i, ck := range inserted[0] {
		if got := ck["doc_id"]; got != docID {
			t.Fatalf("chunks[%d].doc_id = %v, want %q", i, got, docID)
		}
		if got := ck["content_with_weight"]; got == nil || got == "" {
			t.Fatalf("chunks[%d].content_with_weight = %v, want non-empty string", i, got)
		}
	}
}

func TestPipelineExecutor_Run_RealPDF_ProducesIndexedChunks(t *testing.T) {
	requireTokenizerPool(t)

	// Loads service config (server.Init side effect) without requiring any
	// external MySQL/MinIO/ES. The pipeline runs against an in-memory sqlite
	// DB and an in-memory storage backend; chunks are captured via WithInsertFunc.
	mustLoadTaskTestConfig(t)
	origDB := dao.DB
	realDB := mustOpenTaskTestDB(t)
	dao.DB = realDB
	t.Cleanup(func() {
		dao.DB = origDB
	})

	realStorage := storage.NewMemoryStorage()
	origStorage := storage.GetStorageFactory().GetStorage()
	storage.GetStorageFactory().SetStorage(realStorage)
	t.Cleanup(func() {
		storage.GetStorageFactory().SetStorage(origStorage)
	})

	templatePath := filepath.Join(taskRepoRoot(t), "internal", "ingestion", "pipeline", "template", "ingestion_pipeline_general.json")
	templateBytes, err := os.ReadFile(templatePath)
	if err != nil {
		t.Fatalf("read template: %v", err)
	}
	templateBytes = disableTokenizerEmbeddingForTaskTemplate(t, templateBytes)
	var templateDSL entity.JSONMap
	if err = json.Unmarshal(templateBytes, &templateDSL); err != nil {
		t.Fatalf("unmarshal template dsl: %v", err)
	}

	pdfPath := filepath.Join(taskRepoRoot(t), "internal", "deepdoc", "parser", "pdf", "testdata", "pdfs", "01_english_simple.pdf")
	pdfBytes, err := os.ReadFile(pdfPath)
	if err != nil {
		t.Skipf("read pdf fixture %s: %v", pdfPath, err)
		return
	}

	suffix := fmt.Sprintf("%d", time.Now().UnixNano())
	tenantID := taskLimit32("it_tenant_" + suffix)
	kbID := taskLimit32("it_kb_" + suffix)
	docID := taskLimit32("it_doc_" + suffix)
	fileID := taskLimit32("it_file_" + suffix)
	canvasID := taskLimit32("it_canvas_" + suffix)
	bucket := taskS3SafeBucketName(kbID)
	docName := "01_english_simple.pdf"
	objectPath := fmt.Sprintf("integration/task/%s/%s", docID, docName)

	mustSeedTaskRealPipelineDocumentBytes(t, realDB, realStorage, tenantID, kbID, docID, fileID, bucket, objectPath, docName, ".pdf", "pdf", pdfBytes)
	if err = realDB.Model(&entity.Document{}).Where("id = ?", docID).Update("pipeline_id", canvasID).Error; err != nil {
		t.Fatalf("set document pipeline_id: %v", err)
	}
	if err = realDB.Create(&entity.UserCanvas{
		ID:             canvasID,
		UserID:         tenantID,
		Permission:     "me",
		CanvasCategory: "agent_canvas",
		DSL:            templateDSL,
	}).Error; err != nil {
		t.Fatalf("create user canvas: %v", err)
	}
	t.Cleanup(func() {
		_ = realDB.Where("id = ?", canvasID).Delete(&entity.UserCanvas{}).Error
		cleanupTaskRealPipelineDocument(realDB, realStorage, tenantID, kbID, docID, fileID, bucket, objectPath)
	})

	taskCtx := &TaskContext{
		IngestionTask: &entity.IngestionTask{
			ID:         "task-real-pdf-1",
			DocumentID: docID,
			DatasetID:  kbID,
		},
		Doc: entity.Document{
			ID:         docID,
			KbID:       kbID,
			Name:       taskStrPtr(docName),
			PipelineID: taskStrPtr(canvasID),
		},
		KB: entity.Knowledgebase{
			ID:       kbID,
			TenantID: tenantID,
			EmbdID:   "embd-1",
		},
		Tenant: entity.Tenant{ID: tenantID},
	}

	var inserted [][]map[string]any
	svc := mustNewPipelineExecutor(t, taskCtx, canvasID, 0).
		WithInsertFunc(func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
			inserted = append(inserted, deepCopyTaskChunks(chunks))
			return nil, nil
		}).
		WithLogCreateFunc(func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { return nil })

	ctx := t.Context()
	if _, err = svc.Execute(ctx); err != nil {
		t.Fatalf("Run: %v", err)
	}

	if len(inserted) == 0 {
		t.Fatal("no chunks inserted")
	}
	var chunks []map[string]any
	for _, batch := range inserted {
		chunks = append(chunks, batch...)
	}
	if len(chunks) == 0 {
		t.Fatal("inserted 0 chunks")
	}
	sawImage := false
	for i, chunk := range chunks {
		if got := chunk["doc_id"]; got != docID {
			t.Fatalf("chunk[%d].doc_id = %v, want %q", i, got, docID)
		}
		if !taskChunkFieldEqualsStr(chunk["kb_id"], kbID) {
			t.Fatalf("chunk[%d].kb_id = %v, want %q", i, chunk["kb_id"], kbID)
		}
		if got := chunk["docnm_kwd"]; got != docName {
			t.Fatalf("chunk[%d].docnm_kwd = %v, want %q", i, got, docName)
		}
		if got := chunk["content_with_weight"]; got == nil || got == "" {
			t.Fatalf("chunk[%d].content_with_weight = %v, want non-empty", i, got)
		}
		if img, ok := chunk["img_id"]; ok && img != nil && img != "" {
			sawImage = true
		}
	}
	if !sawImage {
		t.Fatal("expected at least one chunk with img_id (pdf preview image uploaded to storage)")
	}
}

func TestRunPipeline_RealPipelineOutput_ProducesIndexFields(t *testing.T) {
	requireTokenizerPool(t)

	mustLoadTaskTestConfig(t)
	origDB := dao.DB
	realDB := mustOpenTaskTestDB(t)
	dao.DB = realDB
	t.Cleanup(func() {
		dao.DB = origDB
	})

	realStorage := storage.NewMemoryStorage()
	origStorage := storage.GetStorageFactory().GetStorage()
	storage.GetStorageFactory().SetStorage(realStorage)
	t.Cleanup(func() {
		storage.GetStorageFactory().SetStorage(origStorage)
	})

	templatePath := filepath.Join(taskRepoRoot(t), "internal", "ingestion", "pipeline", "template", "ingestion_pipeline_general.json")
	templateBytes, err := os.ReadFile(templatePath)
	if err != nil {
		t.Fatalf("read template: %v", err)
	}
	templateBytes = disableTokenizerEmbeddingForTaskTemplate(t, templateBytes)

	pipe, err := pipelinepkg.NewPipelineFromDSL(templateBytes, "task-real-pipeline")
	if err != nil {
		t.Fatalf("NewPipelineFromDSL: %v", err)
	}

	suffix := fmt.Sprintf("%d", time.Now().UnixNano())
	tenantID := taskLimit32("it_tenant_" + suffix)
	kbID := taskLimit32("it_kb_" + suffix)
	docID := taskLimit32("it_doc_" + suffix)
	fileID := taskLimit32("it_file_" + suffix)
	bucket := taskS3SafeBucketName(kbID)
	objectPath := fmt.Sprintf("integration/task/%s/template-general.txt", docID)
	docName := "template-general.txt"
	content := "Alpha paragraph.\n\nBeta paragraph."

	mustSeedTaskRealPipelineDocument(t, realDB, realStorage, tenantID, kbID, docID, fileID, bucket, objectPath, docName, content)
	t.Cleanup(func() {
		cleanupTaskRealPipelineDocument(realDB, realStorage, tenantID, kbID, docID, fileID, bucket, objectPath)
	})

	ctx := t.Context()
	pipelineOut, err := pipe.Run(ctx, map[string]any{
		"doc_id": docID,
	}, nil)
	if err != nil {
		t.Fatalf("pipeline Run: %v", err)
	}
	pipelineOut = taskTerminalPayloadFromRunOutput(t, pipelineOut, "Tokenizer:LegalReadersDecide")

	taskCtx := &TaskContext{
		IngestionTask: &entity.IngestionTask{
			ID:         "task-real-1",
			DocumentID: docID,
			DatasetID:  kbID,
		},
		Doc: entity.Document{
			ID:   docID,
			KbID: kbID,
			Name: taskStrPtr(docName),
		},
		KB: entity.Knowledgebase{
			ID:       kbID,
			TenantID: tenantID,
			EmbdID:   "embd-1",
		},
		Tenant: entity.Tenant{ID: tenantID},
	}

	var inserted [][]map[string]any
	svc := mustNewPipelineExecutor(t, taskCtx, "flow-real-1", 0).
		WithInsertFunc(func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
			inserted = append(inserted, deepCopyTaskChunks(chunks))
			return nil, nil
		})

	if _, err = svc.processOutput(ctx, pipelineOut, time.Now()); err != nil {
		t.Fatalf("RunPipeline: %v", err)
	}

	if len(inserted) != 1 {
		t.Fatalf("insert calls = %d, want 1", len(inserted))
	}
	chunks := inserted[0]
	if len(chunks) != 2 {
		t.Fatalf("inserted chunk count = %d, want 2", len(chunks))
	}
	for i, ck := range chunks {
		if got := ck["doc_id"]; got != docID {
			t.Fatalf("chunks[%d].doc_id = %v, want %q", i, got, docID)
		}
		if got := ck["docnm_kwd"]; got != docName {
			t.Fatalf("chunks[%d].docnm_kwd = %v, want %q", i, got, docName)
		}
		if got := ck["content_with_weight"]; got == nil || got == "" {
			t.Fatalf("chunks[%d].content_with_weight = %v, want non-empty string", i, got)
		}
		if got, ok := ck["content_ltks"].(string); !ok || got == "" {
			t.Fatalf("chunks[%d].content_ltks = %T/%v, want non-empty string", i, ck["content_ltks"], ck["content_ltks"])
		}
		if got, ok := ck["content_sm_ltks"].(string); !ok || got == "" {
			t.Fatalf("chunks[%d].content_sm_ltks = %T/%v, want non-empty string", i, ck["content_sm_ltks"], ck["content_sm_ltks"])
		}
		if _, hasText := ck["text"]; hasText {
			t.Fatalf("chunks[%d] should not keep raw text field after RunPipeline: %v", i, ck["text"])
		}
	}
}

func taskRepoRoot(t *testing.T) string {
	t.Helper()
	wd, err := os.Getwd()
	if err != nil {
		t.Fatalf("getwd: %v", err)
	}
	return filepath.Clean(filepath.Join(wd, "..", "..", ".."))
}

func mustLoadTaskTestConfig(t *testing.T) *config.Config {
	t.Helper()
	if err := common.Init("info", common.FileOutput{}, ""); err != nil {
		t.Fatalf("init common logger: %v", err)
	}
	server.SetLogger(zap.NewNop())
	configPath := filepath.Join(taskRepoRoot(t), "conf", "service_conf.yaml")
	if err := server.Init(configPath); err != nil {
		t.Fatalf("init service config from %s: %v", configPath, err)
	}
	cfg := server.GetConfig()
	if cfg == nil {
		t.Fatal("task test config is nil after server.Init")
	}
	return cfg
}

// mustOpenTaskTestDB opens an isolated in-memory sqlite database and migrates
// the tables the real-pipeline contract tests seed and read. It does not touch
// the filesystem or any external MySQL. The connection pool is pinned to a
// single connection (MaxOpenConns(1)) so the in-memory database is shared
// across all statements — without that, gorm's default pool would hand each
// statement a separate connection, and ":memory:" is per-connection (so seeds
// on one connection would be invisible to reads on another).
func mustOpenTaskTestDB(t *testing.T) *gorm.DB {
	t.Helper()
	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
		Logger: gormlogger.Default.LogMode(gormlogger.Silent),
	})
	if err != nil {
		t.Fatalf("open in-memory sqlite db: %v", err)
	}
	if err := db.AutoMigrate(
		&entity.Tenant{},
		&entity.Knowledgebase{},
		&entity.Document{},
		&entity.File{},
		&entity.File2Document{},
		&entity.UserCanvas{},
		&entity.PipelineOperationLog{},
	); err != nil {
		t.Fatalf("auto-migrate sqlite tables: %v", err)
	}
	if sqlDB, err := db.DB(); err != nil {
		t.Fatalf("get sql.DB from gorm: %v", err)
	} else {
		sqlDB.SetMaxOpenConns(1)
	}
	return db
}

func disableTokenizerEmbeddingForTaskTemplate(t *testing.T, raw []byte) []byte {
	t.Helper()
	var tpl map[string]any
	if err := json.Unmarshal(raw, &tpl); err != nil {
		t.Fatalf("unmarshal template: %v", err)
	}
	dsl, ok := tpl["dsl"].(map[string]any)
	if !ok {
		t.Fatalf("template dsl = %T, want map[string]any", tpl["dsl"])
	}
	components, ok := dsl["components"].(map[string]any)
	if !ok {
		t.Fatalf("template components = %T, want map[string]any", dsl["components"])
	}
	changed := 0
	for _, rawComp := range components {
		comp, ok := rawComp.(map[string]any)
		if !ok {
			continue
		}
		obj, ok := comp["obj"].(map[string]any)
		if !ok || obj["component_name"] != "Tokenizer" {
			continue
		}
		params, ok := obj["params"].(map[string]any)
		if !ok {
			continue
		}
		params["search_method"] = []string{"full_text"}
		changed++
	}
	if changed == 0 {
		t.Fatal("no Tokenizer component found to disable embedding")
	}
	out, err := json.Marshal(tpl)
	if err != nil {
		t.Fatalf("marshal modified template: %v", err)
	}
	return out
}

func taskMustSymlink(t *testing.T, src, dst string) {
	t.Helper()
	if err := os.Symlink(src, dst); err != nil {
		t.Fatalf("symlink tokenizer resource %s -> %s: %v", src, dst, err)
	}
}

func taskMustWriteTokenizerPOSDef(t *testing.T, dictPath, outPath string) {
	t.Helper()
	data, err := os.ReadFile(dictPath)
	if err != nil {
		t.Fatalf("read tokenizer dict %s: %v", dictPath, err)
	}
	posSet := map[string]struct{}{}
	for _, line := range strings.Split(string(data), "\n") {
		fields := strings.Fields(line)
		if len(fields) == 3 {
			posSet[fields[2]] = struct{}{}
		}
	}
	if len(posSet) == 0 {
		t.Fatalf("no POS tags parsed from tokenizer dict %s", dictPath)
	}
	posList := make([]string, 0, len(posSet))
	for pos := range posSet {
		posList = append(posList, pos)
	}
	sort.Strings(posList)
	content := strings.Join(posList, "\n") + "\n"
	if err := os.WriteFile(outPath, []byte(content), 0o644); err != nil {
		t.Fatalf("write tokenizer pos file %s: %v", outPath, err)
	}
}

func taskMustPrepareTokenizerWordNet(t *testing.T, root string) {
	t.Helper()
	zipPath := filepath.Join(taskRepoRoot(t), "ragflow_deps", "nltk_data", "corpora", "wordnet.zip")
	reader, err := zip.OpenReader(zipPath)
	if err != nil {
		t.Skipf("open wordnet zip %s: %v", zipPath, err)
		return
	}
	defer func() { _ = reader.Close() }()
	for _, f := range reader.File {
		name := strings.TrimPrefix(f.Name, "wordnet/")
		if name == "" || strings.HasSuffix(name, "/") {
			continue
		}
		dst := filepath.Join(root, "wordnet", name)
		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
			t.Fatalf("mkdir wordnet dst %s: %v", dst, err)
		}
		rc, err := f.Open()
		if err != nil {
			t.Fatalf("open wordnet entry %s: %v", f.Name, err)
		}
		out, err := os.Create(dst)
		if err != nil {
			_ = rc.Close()
			t.Fatalf("create wordnet dst %s: %v", dst, err)
		}
		if _, err := io.Copy(out, rc); err != nil {
			_ = out.Close()
			_ = rc.Close()
			t.Fatalf("copy wordnet entry %s -> %s: %v", f.Name, dst, err)
		}
		if err := out.Close(); err != nil {
			_ = rc.Close()
			t.Fatalf("close wordnet dst %s: %v", dst, err)
		}
		if err := rc.Close(); err != nil {
			t.Fatalf("close wordnet entry %s: %v", f.Name, err)
		}
	}
}

func taskMustPrepareTokenizerOpenCC(t *testing.T, root string) {
	t.Helper()
	const systemOpenCC = "/usr/share/opencc"
	if _, err := os.Stat(systemOpenCC); err != nil {
		t.Skipf("system opencc dir %s not found: %v", systemOpenCC, err)
		return
	}
	taskMustSymlink(t, systemOpenCC, filepath.Join(root, "opencc"))
}

func requireTokenizerPool(t *testing.T) {
	t.Helper()
	if err := tokenizer.Init(&tokenizer.PoolConfig{
		DictPath:       "/usr/share/infinity/resource",
		MinSize:        1,
		MaxSize:        2,
		IdleTimeout:    30 * time.Second,
		AcquireTimeout: 5 * time.Second,
	}); err != nil {
		t.Skipf("tokenizer pool init failed: %v", err)
	}
}

func mustSeedTaskRealPipelineDocument(
	t *testing.T,
	db *gorm.DB,
	stg storage.Storage,
	tenantID, kbID, docID, fileID, bucket, objectPath, docName, content string,
) {
	mustSeedTaskRealPipelineDocumentBytes(t, db, stg, tenantID, kbID, docID, fileID, bucket, objectPath, docName, ".txt", "txt", []byte(content))
}

func mustSeedTaskRealPipelineDocumentBytes(
	t *testing.T,
	db *gorm.DB,
	stg storage.Storage,
	tenantID, kbID, docID, fileID, bucket, objectPath, docName, suffix, docType string,
	content []byte,
) {
	t.Helper()
	ocrID := "ocr-default"
	if err := db.Create(&entity.Tenant{
		ID:        tenantID,
		LLMID:     "gpt-4",
		ASRID:     "asr-default",
		Img2TxtID: "img2txt-default",
		RerankID:  "rerank-default",
		ParserIDs: "parser-default",
		OCRID:     &ocrID,
		Status:    taskStrPtr("1"),
	}).Error; err != nil {
		t.Fatalf("create tenant: %v", err)
	}
	if err := db.Create(&entity.Knowledgebase{
		ID:           kbID,
		TenantID:     tenantID,
		EmbdID:       "embd-1",
		ParserConfig: entity.JSONMap{},
		Status:       taskStrPtr("1"),
	}).Error; err != nil {
		t.Fatalf("create kb: %v", err)
	}
	if err := stg.Put(bucket, objectPath, content); err != nil {
		t.Fatalf("put real minio object: %v", err)
	}
	if err := db.Create(&entity.File{
		ID:         fileID,
		ParentID:   bucket,
		TenantID:   tenantID,
		CreatedBy:  tenantID,
		Name:       docName,
		Type:       docType,
		Location:   taskStrPtr(objectPath),
		SourceType: "",
	}).Error; err != nil {
		t.Fatalf("create file: %v", err)
	}
	if err := db.Create(&entity.Document{
		ID:           docID,
		KbID:         kbID,
		ParserID:     "naive",
		ParserConfig: entity.JSONMap{},
		SourceType:   "local",
		Type:         docType,
		CreatedBy:    tenantID,
		Name:         taskStrPtr(docName),
		Location:     taskStrPtr(objectPath),
		Suffix:       suffix,
		Status:       taskStrPtr("1"),
	}).Error; err != nil {
		t.Fatalf("create document: %v", err)
	}
	if err := db.Create(&entity.File2Document{
		ID:         taskLimit32("it_map_" + docID),
		FileID:     taskStrPtr(fileID),
		DocumentID: taskStrPtr(docID),
	}).Error; err != nil {
		t.Fatalf("create file2document: %v", err)
	}
}

func cleanupTaskRealPipelineDocument(db *gorm.DB, stg storage.Storage, tenantID, kbID, docID, fileID, bucket, objectPath string) {
	_ = db.Where("document_id = ?", docID).Delete(&entity.File2Document{}).Error
	_ = db.Where("id = ?", docID).Delete(&entity.Document{}).Error
	_ = db.Where("id = ?", fileID).Delete(&entity.File{}).Error
	_ = db.Where("id = ?", kbID).Delete(&entity.Knowledgebase{}).Error
	_ = db.Where("id = ?", tenantID).Delete(&entity.Tenant{}).Error
	_ = stg.Remove(bucket, objectPath)
}

func deepCopyTaskChunks(in []map[string]any) []map[string]any {
	if len(in) == 0 {
		return nil
	}
	out := make([]map[string]any, len(in))
	for i, ck := range in {
		data, err := json.Marshal(ck)
		if err != nil {
			panic(err)
		}
		var copied map[string]any
		if err := json.Unmarshal(data, &copied); err != nil {
			panic(err)
		}
		out[i] = copied
	}
	return out
}

func taskTerminalPayloadFromRunOutput(t *testing.T, out map[string]any, terminalID string) map[string]any {
	t.Helper()
	if out == nil {
		t.Fatal("Run returned nil output")
	}
	if _, ok := out["output_format"]; ok {
		return out
	}
	nested, ok := out[terminalID].(map[string]any)
	if !ok {
		t.Fatalf("run output missing terminal payload %q in %v", terminalID, out)
	}
	return nested
}

func taskStrPtr(s string) *string { return &s }

func taskLimit32(s string) string {
	if len(s) <= 32 {
		return s
	}
	return s[:32]
}

func taskS3SafeBucketName(s string) string {
	s = strings.ToLower(s)
	s = strings.ReplaceAll(s, "_", "-")
	return s
}

// taskChunkFieldEqualsStr compares a chunk field to a plain string, tolerating
// the slice form used internally (e.g. kb_id is []string{kbID} and survives a
// JSON round-trip as []any{kbID}).
func taskChunkFieldEqualsStr(v any, want string) bool {
	switch val := v.(type) {
	case string:
		return val == want
	case []string:
		return len(val) == 1 && val[0] == want
	case []any:
		return len(val) == 1 && fmt.Sprint(val[0]) == want
	}
	return false
}
