package rag

import (
	"strings"
	"testing"
	"unicode/utf8"
)

func TestChunkEmpty(t *testing.T) {
	if got := chunk("   \n\n  "); len(got) != 0 {
		t.Fatalf("空白文本应切出 0 块，得 %d", len(got))
	}
}

func TestChunkShort(t *testing.T) {
	got := chunk("这是一句很短的话。")
	if len(got) != 1 || !strings.Contains(got[0], "很短") {
		t.Fatalf("短文本应为单块，得 %v", got)
	}
}

// TestChunkRuneSafe 是核心：中文必须按 rune 切，绝不能切碎出乱码（旧版按字节切的 bug）。
func TestChunkRuneSafe(t *testing.T) {
	// 1500 个汉字、无标点 → 触发窗口兜底切；每块必须是合法 UTF-8。
	text := strings.Repeat("中", 1500)
	for _, c := range chunk(text) {
		if !utf8.ValidString(c) {
			t.Fatalf("切出非法 UTF-8（中文被字节切碎）：%q", c)
		}
		for _, r := range c {
			if r != '中' && !strings.ContainsRune("\n ", r) {
				t.Fatalf("出现意外字符 %q，疑似切碎", r)
			}
		}
	}
}

func TestChunkSizeBounds(t *testing.T) {
	// 多段中文，每段以句号结尾。
	var sb strings.Builder
	for i := 0; i < 60; i++ {
		sb.WriteString("这是用于测试切块大小上界的一个中文句子片段。")
	}
	for i, c := range chunk(sb.String()) {
		if n := runeLen(c); n > chunkTargetRunes+chunkOverlapRunes+1 {
			t.Fatalf("第 %d 块 %d 字，超过 target+overlap=%d", i, n, chunkTargetRunes+chunkOverlapRunes+1)
		}
	}
}

func TestChunkSentenceBoundary(t *testing.T) {
	var sb strings.Builder
	for i := 0; i < 80; i++ {
		sb.WriteString("第一句话在这里。第二句话也在这里。")
	}
	chunks := chunk(sb.String())
	if len(chunks) < 2 {
		t.Fatalf("长文本应切出多块，得 %d", len(chunks))
	}
	// 多数块应以句号收口（允许重叠导致的少量例外）。
	endsWell := 0
	for _, c := range chunks {
		if strings.HasSuffix(strings.TrimSpace(c), "。") {
			endsWell++
		}
	}
	if endsWell < len(chunks)/2 {
		t.Fatalf("多数块应在句末收口，仅 %d/%d", endsWell, len(chunks))
	}
}

func TestChunkOverlap(t *testing.T) {
	var sb strings.Builder
	for i := 0; i < 60; i++ {
		sb.WriteString("用于验证相邻块之间存在上下文重叠的中文句子。")
	}
	chunks := chunk(sb.String())
	if len(chunks) < 2 {
		t.Skip("未切出多块，跳过重叠校验")
	}
	// 第 2 块开头应包含第 1 块尾部的一小段（重叠）。
	prevTail := []rune(chunks[0])
	tail := string(prevTail[max0(len(prevTail)-chunkOverlapRunes):])
	// 取尾部一小片做包含判断（去掉可能的换行）。
	probe := strings.TrimSpace(tail)
	if len(probe) > 10 {
		probe = probe[len(probe)-10:]
	}
	if probe != "" && !strings.Contains(chunks[1], strings.TrimSpace(string([]rune(probe)))) {
		// 重叠是按 rune 尾部，probe 是字节尾部，宽松校验：第二块前缀应与首块尾部有交集
		if !strings.HasPrefix(strings.TrimSpace(chunks[1]), strings.TrimSpace(tail)) {
			t.Logf("重叠片段:%q\n块2前缀:%q", tail, []rune(chunks[1])[:min0(40, runeLen(chunks[1]))])
			t.Fatalf("相邻块未见重叠")
		}
	}
}

func TestChunkOversizedNoPunct(t *testing.T) {
	text := strings.Repeat("x", 3000) // 无标点超大块
	chunks := chunk(text)
	if len(chunks) < 3 {
		t.Fatalf("3000 字无标点应窗口切成多块，得 %d", len(chunks))
	}
	for _, c := range chunks {
		if runeLen(c) > chunkTargetRunes+chunkOverlapRunes+1 {
			t.Fatalf("窗口切块超界：%d", runeLen(c))
		}
	}
}

// ---- Markdown 标题感知切块 ----

const mdDoc = `# 部署指南
本文介绍如何部署本系统的前言段落。

## 环境要求
需要 Go 1.25 与 Docker 运行环境。

### 内存
建议至少 8GB 内存以保证向量库稳定。

## 启动
执行 docker compose up 即可拉起全部服务。
`

func TestChunkMarkdownBreadcrumb(t *testing.T) {
	chunks := chunk(mdDoc)
	// 「内存」节的块应带完整三级面包屑。
	var memChunk string
	for _, c := range chunks {
		if strings.Contains(c, "8GB") {
			memChunk = c
		}
	}
	if memChunk == "" {
		t.Fatal("未找到内存节的块")
	}
	if !strings.HasPrefix(memChunk, "部署指南 > 环境要求 > 内存\n") {
		t.Fatalf("内存块应以三级面包屑开头，得：%q", memChunk)
	}
}

func TestChunkMarkdownNoCrossSection(t *testing.T) {
	for _, c := range chunk(mdDoc) {
		// 任一块都不应同时含两个不同章节的正文（块不跨章节）。
		if strings.Contains(c, "8GB") && strings.Contains(c, "docker compose") {
			t.Fatalf("块跨越了章节边界：%q", c)
		}
	}
}

func TestChunkMarkdownHierarchyReset(t *testing.T) {
	// 同级 ## 启动 应把更深的 ### 内存 出栈，面包屑回到「部署指南 > 启动」。
	var startChunk string
	for _, c := range chunk(mdDoc) {
		if strings.Contains(c, "docker compose") {
			startChunk = c
		}
	}
	if !strings.HasPrefix(startChunk, "部署指南 > 启动\n") {
		t.Fatalf("启动块面包屑应为两级（内存已出栈），得：%q", startChunk)
	}
}

func TestChunkMarkdownCodeFenceIgnored(t *testing.T) {
	doc := strings.Join([]string{
		"# 脚本说明",
		"下面给出示例脚本：",
		"",
		"```bash",
		"# 这是注释不是标题",
		"echo hi",
		"```",
		"",
		"脚本到此结束。",
	}, "\n")
	chunks := chunk(doc)
	for _, c := range chunks {
		// 围栏内的「# 这是注释不是标题」绝不能被当成标题（不会成为面包屑首行）。
		if strings.HasPrefix(c, "这是注释不是标题") {
			t.Fatalf("代码围栏内的 # 被误判为标题：%q", c)
		}
		if !strings.HasPrefix(c, "脚本说明\n") {
			t.Fatalf("块应统一挂在「脚本说明」下，得：%q", c)
		}
	}
	if !strings.Contains(strings.Join(chunks, "\n"), "echo hi") {
		t.Fatal("代码块正文应被保留")
	}
}

func TestChunkPlainTextUnaffected(t *testing.T) {
	// 无标题文本应与纯语义切块完全一致（向后兼容，不引入面包屑）。
	plain := strings.Repeat("这是一段没有任何标题的普通中文文本。", 40)
	got := chunk(plain)
	want := packSection(plain)
	if len(got) != len(want) {
		t.Fatalf("纯文本切块数变了：got %d want %d", len(got), len(want))
	}
	for i := range got {
		if got[i] != want[i] {
			t.Fatalf("纯文本第 %d 块与语义切块不一致", i)
		}
		if strings.Contains(got[i], " > ") {
			t.Fatalf("纯文本不应出现面包屑：%q", got[i])
		}
	}
}

func max0(n int) int {
	if n < 0 {
		return 0
	}
	return n
}
func min0(a, b int) int {
	if a < b {
		return a
	}
	return b
}
