// lint.go — daemon 端知識卡品質檢查（B2 萃取品質驗收標準）。
//
// 真相源＝InkStoneCo 頂層卷 `system-dev/docs/3-specs/daemon-beta/b2-quality-standard-draft.md`
// （§1 硬標準 H1–H6、§6 總管自裁 2026-07-26 commit 39bbdd2：T1-T5 定案＋施工順序）。
//
// 落點（草案 §3 第一層「品質迴路」）：插在 extract 之後、POST rag_ingest_card 之前。
// 手上有原稿（H6 相似度只有這裡能做）；不過的分級依 §6 裁決 T4-C：
//   - 硬缺（H1 段名／H2 一句話定義／H5 機敏值）＝結構壞或洩密 → 拒收（不 POST）。
//   - 軟項（H3 要點條數／H4 關聯漂移／H6 相似度／H7 憑空指稱／H8 宣稱通膨）＝
//     現行管線會產生的正常瑕疵 → 照送、標 quality:low。
//   - --strict：軟項也擋（測試／CI 用，讓「形檢天花板」外的問題全部現形）。
//
// 🔴 **H7/H8（InkStoneCo#44，2026-08-16 加）是這裡唯一問「內容是不是真的」的一項。**
// H1–H6 全是形檢：段名齊不齊、有沒有照抄、有沒有洩密——**沒有任何一項問過
// 「卡上宣稱的東西，原文裡到底有沒有」**。而 08-16 實測的事故正是這個洞：
// 同一個 220 bytes 的檔在兩台實例上，一台萃出四條全部追得回原文，
// 一台萃出八條、四條憑空，**而那張假卡帶著真實存在的 source_path**。
// 判準與刻意留下的盲區全文寫在 `grounding.go` 檔頭，改它之前先讀那一段。
//
// 誠實限制（抄 wiki-secret-scan.sh 的自陳）：H5 regex 偵測有偽陰/偽陽，擋的是「明顯特徵的
// 機敏字串被自動抄進卡」，擋不了刻意混淆；H3/H4/H6 是形檢，擋形不擋神（草案 §H3 ZZ-T7 天花板）；
// H7 只認「翻譯與改寫帶不走的錨點」（拉丁專名、兩位數以上的數字），純中文的憑空宣稱抓不到。
package collector

import (
	"regexp"
	"strings"
)

// daemon 形卡片四段名（rag-extract-file.md 規約，一字不差）。
const (
	secDef = "一句話定義"
	secPts = "要點"
	secEnt = "關鍵實體"
	secRel = "關聯"
)

// H2 一句話定義長度上限（全形字＝rune）；草案 §H2「建議加長度上限 ≤ 80」。
const defMaxRunes = 80

// H6 相似度門檻（草案 §H6）：8-gram 重疊率上限、卡片字數對原稿比例上限、絕對字數上限。
const (
	h6GramSize     = 8
	h6OverlapRatio = 0.40
	h6MaxCardRunes = 4000
	h6SrcRatio     = 0.60
)

// LintFinding 一條檢查結果（附 H 代號，方便回報與分級）。
type LintFinding struct {
	Code    string `json:"code"`    // H1..H6
	Message string `json:"message"` // 白話說明
}

// LintResult 一張卡的品質檢查結果。
//   - Hard：硬缺（拒收）——H1/H2/H5。
//   - Soft：軟項（收但標 quality:low）——H3/H4/H6。
type LintResult struct {
	Hard []LintFinding `json:"hard,omitempty"`
	Soft []LintFinding `json:"soft,omitempty"`
}

func (r LintResult) addHard(code, msg string) LintResult {
	r.Hard = append(r.Hard, LintFinding{code, msg})
	return r
}

func (r LintResult) addSoft(code, msg string) LintResult {
	r.Soft = append(r.Soft, LintFinding{code, msg})
	return r
}

// Blocks 回傳這張卡是否該被擋下不 POST。
// 非 strict：只有硬缺擋。strict：硬缺或軟項任一即擋。
func (r LintResult) Blocks(strict bool) bool {
	if len(r.Hard) > 0 {
		return true
	}
	return strict && len(r.Soft) > 0
}

// Messages 把 findings 攤成訊息清單（回報用）。
func messagesOf(fs []LintFinding) []string {
	out := make([]string, 0, len(fs))
	for _, f := range fs {
		out = append(out, f.Code+": "+f.Message)
	}
	return out
}

// HardMessages / SoftMessages 供 direct.go 組回報訊息與 quality_warnings。
func (r LintResult) HardMessages() []string { return messagesOf(r.Hard) }
func (r LintResult) SoftMessages() []string { return messagesOf(r.Soft) }

// LintOptions 檢查旁路資訊。
type LintOptions struct {
	Source string // 原稿內容；空＝跳過 H6（rag_ingest_card 側天生收不到原稿）
}

// cardSection 一個 `## 段名` 區塊：名稱、出現序、段內原始行（不含標題行本身）。
type cardSection struct {
	name  string
	order int
	lines []string
}

var (
	h2HeadingRe  = regexp.MustCompile(`^##\s+(.+?)\s*$`)
	anyHeadingRe = regexp.MustCompile(`^#{1,6}\s`)
	bulletRe     = regexp.MustCompile(`^-\s+(.+?)\s*$`)
	boldNameRe   = regexp.MustCompile(`\*\*(.+?)\*\*`)
	// 關聯三元組：`- 主詞 >> 謂詞 >> 受詞`（>> 前後空白寬鬆）。
	relTripleRe = regexp.MustCompile(`^-\s+(.+?)\s*>>\s*(.+?)\s*>>\s*(.+?)\s*$`)
)

// parseSections 抽出所有 `## ` 段落（保序），供 H1/H2/H3/H4 用。
func parseSections(card string) []cardSection {
	var secs []cardSection
	var cur *cardSection
	order := 0
	for _, line := range strings.Split(card, "\n") {
		if m := h2HeadingRe.FindStringSubmatch(line); m != nil {
			secs = append(secs, cardSection{name: strings.TrimSpace(m[1]), order: order})
			cur = &secs[len(secs)-1]
			order++
			continue
		}
		if cur != nil {
			cur.lines = append(cur.lines, line)
		}
	}
	return secs
}

// sectionByName 找指定段名（一字不差）；回傳指標與是否存在。
func sectionByName(secs []cardSection, name string) (*cardSection, bool) {
	for i := range secs {
		if secs[i].name == name {
			return &secs[i], true
		}
	}
	return nil, false
}

// nonEmptyLines 過濾空白行（trim 後為空）。
func nonEmptyLines(lines []string) []string {
	var out []string
	for _, l := range lines {
		if strings.TrimSpace(l) != "" {
			out = append(out, l)
		}
	}
	return out
}

// runeLen 全形字數（rune 計數）。
func runeLen(s string) int { return len([]rune(s)) }

// ── 新格式（InkStoneCo#44 ④，2026-08-15）：wikishape.go 產的規範形卡 ──
// frontmatter（tags/gloss/created/updated）＋「## 摘要／## 重點／## 實體／## 關聯」。
// 舊四段格式（一句話定義/要點/關鍵實體/關聯）仍由 workers-ai 雲端路產出，雙軌並存：
// 雲端 prompt 升級歸 matrix/arcrun（B 類），在那之前這裡不能把舊卡全打成硬缺。
const (
	nsecSum = "摘要"
	nsecPts = "重點"
	nsecEnt = "實體"
	nsecRel = "關聯"
)

var glossRe = regexp.MustCompile(`(?m)^gloss:\s*(.*)$`)

// isWikiShapeCard 認新格式：frontmatter 開頭且帶 gloss。
func isWikiShapeCard(card string) bool {
	return strings.HasPrefix(card, "---\n") && glossRe.MatchString(card)
}

// lintWikiShapeCard 對新格式卡跑等價的 H1–H6（代號沿用，回報端不用改）。
func lintWikiShapeCard(card string, opts LintOptions) LintResult {
	var r LintResult
	secs := parseSections(card)

	// H1 四段齊且順序對（硬）。
	required := []string{nsecSum, nsecPts, nsecEnt, nsecRel}
	idx := map[string]int{}
	var missing []string
	for _, name := range required {
		if s, ok := sectionByName(secs, name); ok {
			idx[name] = s.order
		} else {
			missing = append(missing, name)
		}
	}
	if len(missing) > 0 {
		r = r.addHard("H1", "缺段名："+strings.Join(missing, "、")+"（四段須齊：摘要→重點→實體→關聯）")
	} else if !(idx[nsecSum] < idx[nsecPts] && idx[nsecPts] < idx[nsecEnt] && idx[nsecEnt] < idx[nsecRel]) {
		r = r.addHard("H1", "段名順序錯（須依序：摘要→重點→實體→關聯）")
	}

	// H2 gloss 恰一句、非空（硬）；過長軟提醒。
	if m := glossRe.FindStringSubmatch(card); m != nil {
		g := strings.TrimSpace(m[1])
		switch {
		case g == "":
			r = r.addHard("H2", "frontmatter 的 gloss 是空的")
		case runeLen(g) > defMaxRunes:
			r = r.addSoft("H2", "gloss "+itoa(runeLen(g))+" 全形字（建議 ≤80，過長像小作文）")
		}
	} else {
		r = r.addHard("H2", "frontmatter 缺 gloss")
	}

	// H3 重點條數（軟）：原子卡自然落在 1–8，超過 12 是沒消化的地毯複製。
	if pts, ok := sectionByName(secs, nsecPts); ok {
		bullets := 0
		for _, l := range nonEmptyLines(pts.lines) {
			if bulletRe.MatchString(l) {
				bullets++
			}
		}
		if bullets < 1 || bullets > 12 {
			r = r.addSoft("H3", "重點 "+itoa(bullets)+" 條（應 1–12 條）")
		}
	}

	// H4 三元組行形（軟）：每行恰三項，述詞裡不得再夾分隔符。
	if rel, ok := sectionByName(secs, nsecRel); ok {
		for _, l := range nonEmptyLines(rel.lines) {
			if !strings.HasPrefix(strings.TrimSpace(l), "- ") {
				continue
			}
			if strings.Count(l, triSep) > 2 {
				r = r.addSoft("H4", "三元組不是三項（述詞裡夾了分隔符）："+trimForMsg(l))
			}
		}
	}

	// H5 機敏值（硬）與 H6 相似度（軟）：與舊格式同一套。
	if hits := scanSecrets(card); len(hits) > 0 {
		r = r.addHard("H5", "疑似機敏值："+strings.Join(hits, "、")+"（依規約該段應改寫成描述，不得照抄）")
	}
	if opts.Source != "" {
		if msg := checkSimilarity(card, opts.Source); msg != "" {
			r = r.addSoft("H6", msg)
		}
		r = r.addGrounding(card, opts.Source)
	}
	return r
}

// addGrounding 跑 H7/H8 落地檢查並掛成軟項（grounding.go；InkStoneCo#44 2026-08-16）。
// 🔴 軟項不是「比較不重要」，是「不准靜默丟掉使用者的知識」——見 grounding.go 檔頭分級段。
func (r LintResult) addGrounding(card, source string) LintResult {
	for _, f := range groundingFindings(CheckGrounding(card, source)) {
		r = r.addSoft(f[0], f[1])
	}
	return r
}

// LintCard 對一張 daemon 形知識卡跑 H1–H6，回傳分級結果（B2 §1＋§6 T4-C）。
// 新格式（wikishape）與舊格式（workers-ai 雲端路）雙軌：先認格式再挑對應規則。
func LintCard(card string, opts LintOptions) LintResult {
	if isWikiShapeCard(card) {
		return lintWikiShapeCard(card, opts)
	}
	var r LintResult
	secs := parseSections(card)

	// ── H1　段名齊全且順序正確（硬）──────────────────────────────
	required := []string{secDef, secPts, secEnt, secRel}
	idx := map[string]int{}
	var missing []string
	for _, name := range required {
		if s, ok := sectionByName(secs, name); ok {
			idx[name] = s.order
		} else {
			missing = append(missing, name)
		}
	}
	if len(missing) > 0 {
		r = r.addHard("H1", "缺段名："+strings.Join(missing, "、")+"（四段須齊：一句話定義→要點→關鍵實體→關聯）")
	} else if !(idx[secDef] < idx[secPts] && idx[secPts] < idx[secEnt] && idx[secEnt] < idx[secRel]) {
		r = r.addHard("H1", "段名順序錯（須依序：一句話定義→要點→關鍵實體→關聯）")
	}

	// ── H2　一句話定義恰一行；> 80 全形字＝軟提醒（硬/軟）─────────────
	if def, ok := sectionByName(secs, secDef); ok {
		body := nonEmptyLines(def.lines)
		switch {
		case len(body) == 0:
			r = r.addHard("H2", "一句話定義段是空的")
		case len(body) > 1:
			r = r.addHard("H2", "一句話定義有 "+itoa(len(body))+" 行（須恰一行；多行＝那是摘要不是定義）")
		default:
			if n := runeLen(strings.TrimSpace(body[0])); n > defMaxRunes {
				r = r.addSoft("H2", "一句話定義 "+itoa(n)+" 全形字（建議 ≤80，過長像小作文）")
			}
		}
	}

	// ── H3　要點 3–12 條、不夾散文（軟）───────────────────────────
	if pts, ok := sectionByName(secs, secPts); ok {
		body := nonEmptyLines(pts.lines)
		bullets := 0
		prose := false
		for _, l := range body {
			if bulletRe.MatchString(l) {
				bullets++
			} else {
				prose = true
			}
		}
		if prose {
			r = r.addSoft("H3", "要點段夾了非列點的散文行（要點應為 - 開頭的 bullet）")
		}
		if bullets < 3 || bullets > 12 {
			r = r.addSoft("H3", "要點 "+itoa(bullets)+" 條（應 3–12 條；太少該併回定義、太多是沒消化的地毯複製）")
		}
	}

	// ── H4　關聯行形＋端點閉合（軟；草案裁決 T4-C 全歸軟）──────────
	if rel, ok := sectionByName(secs, secRel); ok {
		entities := entityNames(secs)
		body := nonEmptyLines(rel.lines)
		valid := 0
		for _, l := range body {
			m := relTripleRe.FindStringSubmatch(l)
			if m == nil {
				r = r.addSoft("H4", "關聯行不合三元組形（須「- 主詞 >> 謂詞 >> 受詞」）："+trimForMsg(l))
				continue
			}
			valid++
			subj, obj := strings.TrimSpace(m[1]), strings.TrimSpace(m[3])
			for _, end := range []string{subj, obj} {
				// H1 缺關鍵實體段時 entities 為空，端點檢查會全報漂移＝雜訊，故僅在有實體清單時查。
				if len(entities) > 0 && !entities[end] {
					r = r.addSoft("H4", "關聯端點「"+end+"」不在關鍵實體清單（會長出無 gloss 的孤兒節點）")
				}
			}
		}
		if valid < 3 || valid > 8 {
			r = r.addSoft("H4", "關聯 "+itoa(valid)+" 條合法三元組（應 3–8 條）")
		}
	}

	// ── H5　不含機敏值（硬）───────────────────────────────────────
	if hits := scanSecrets(card); len(hits) > 0 {
		r = r.addHard("H5", "疑似機敏值："+strings.Join(hits, "、")+"（依規約該段應改寫成描述，不得照抄）")
	}

	// ── H6　不整段複製原文（軟；只有拿得到原稿時做）──────────────
	// ── H7/H8　卡上的宣稱在原文有沒有落地（軟；同樣只有拿得到原稿時做）──
	if opts.Source != "" {
		if msg := checkSimilarity(card, opts.Source); msg != "" {
			r = r.addSoft("H6", msg)
		}
		r = r.addGrounding(card, opts.Source)
	}

	return r
}

// entityNames 從「關鍵實體」段抽出粗體正規名集合（H4 端點閉合用）。
func entityNames(secs []cardSection) map[string]bool {
	set := map[string]bool{}
	ent, ok := sectionByName(secs, secEnt)
	if !ok {
		return set
	}
	for _, l := range ent.lines {
		if m := boldNameRe.FindStringSubmatch(l); m != nil {
			set[strings.TrimSpace(m[1])] = true
		}
	}
	return set
}

// cardBody 取卡片非標題行（H6 比對用；標題行不算複製原文）。
func cardBody(card string) string {
	var b strings.Builder
	for _, l := range strings.Split(card, "\n") {
		if anyHeadingRe.MatchString(l) {
			continue
		}
		b.WriteString(l)
		b.WriteByte('\n')
	}
	return b.String()
}

// checkSimilarity 粗檢卡片是否整段複製原文（草案 §H6 (b) 8-gram 重疊＋絕對字數上限）。
// 回傳非空＝命中（訊息）；空＝過。
func checkSimilarity(card, source string) string {
	body := cardBody(card)
	cardRunes := runeLen(strings.TrimSpace(body))
	srcRunes := runeLen(source)

	// 絕對上限：卡片字數 ≤ min(4000, 原稿字數 × 60%)——防「萃取失敗就整檔照抄」。
	//
	// ⚠️ **已知偽陽（InkStoneCo#44 2026-08-16 實測，提案在 pending-changes.md 等 confirm）**：
	// 這條比例規則對**薄原稿是反的**。220 字的原稿 ⇒ 上限 132 字，
	// 而一張規範形卡光是機械骨架就三百多字（實測忠實卡 471、編造卡 550）
	// ⇒ **兩張都超標，忠實的那張同樣被打成「疑似整段照抄」**。
	// 🔴 這條沒有在本票一併改：它是 B2 §H6 的硬標準，且有兩個既有測試
	//（TestLintH6LengthCapSoft／TestLintH6OverlapSoft）就寫在這個行為上
	// ⇒ 屬規格層變更，依 SDD 生命週期鐵律第 3 條走提案、不自行改。
	limit := h6MaxCardRunes
	if byRatio := int(float64(srcRunes) * h6SrcRatio); byRatio < limit {
		limit = byRatio
	}
	if cardRunes > limit {
		return "卡片 " + itoa(cardRunes) + " 全形字，超過上限 " + itoa(limit) + "（min(4000, 原稿×60%)）＝疑似整段照抄"
	}

	// 8-gram 重疊率。
	cardGrams := runeGrams(body, h6GramSize)
	if len(cardGrams) == 0 {
		return ""
	}
	srcSet := gramSet(source, h6GramSize)
	matched := 0
	for g := range cardGrams {
		if srcSet[g] {
			matched++
		}
	}
	ratio := float64(matched) / float64(len(cardGrams))
	if ratio >= h6OverlapRatio {
		return "卡片與原稿 8-gram 重疊率 " + pct(ratio) + "（上限 " + pct(h6OverlapRatio) + "）＝地毯複製，違 D16 精耕非 RAG"
	}
	return ""
}

// runeGrams 回傳去重後的 rune n-gram 集合（跳過純空白 gram）。
func runeGrams(s string, n int) map[string]bool {
	set := map[string]bool{}
	rs := []rune(s)
	for i := 0; i+n <= len(rs); i++ {
		g := string(rs[i : i+n])
		if strings.TrimSpace(g) == "" {
			continue
		}
		set[g] = true
	}
	return set
}

// gramSet 同 runeGrams（別名，語意上是「原稿的 gram 索引」）。
func gramSet(s string, n int) map[string]bool { return runeGrams(s, n) }

// ── H5 機敏值 pattern（移植 .claude/hooks/wiki-secret-scan.sh，不重造輪子）──
// 每條一個標籤＋regexp；行內含 wiki-secret-ok 標記者略過。RE2 無 backref，原 pattern 皆不需要。
type secretPattern struct {
	label string
	re    *regexp.Regexp
}

var secretPatterns = []secretPattern{
	{"密碼/密鑰賦值", regexp.MustCompile(`(?i)(pass(word)?|secret|api[_-]?key|access[_-]?key|auth[_-]?token|priv(ate)?[_-]?key)[[:space:]]*[:=][[:space:]]*[^[:space:]<>"']{6,}`)},
	{"私鑰 PEM 區塊", regexp.MustCompile(`-----BEGIN[[:space:]].*PRIVATE KEY-----`)},
	{"服務金鑰特徵", regexp.MustCompile(`(AKIA[0-9A-Z]{16}|gh[pousr]_[0-9A-Za-z]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|AIza[0-9A-Za-z_-]{20,}|sk_(live|test)_[0-9A-Za-z]{16,})`)},
	{"JWT token", regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`)},
	{"連線字串內嵌帳密", regexp.MustCompile(`(?i)[a-z][a-z0-9+.-]*://[^[:space:]:/@]+:[^[:space:]:/@]+@`)},
	{"台灣身分證字號", regexp.MustCompile(`(^|[^A-Za-z0-9])[A-Z][12][0-9]{8}([^0-9]|$)`)},
	{"疑似信用卡號", regexp.MustCompile(`(^|[^0-9])[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{0,4}([^0-9]|$)`)},
}

// scanSecrets 逐行掃機敏特徵（與 shell hook 的 line-oriented grep 同語意），回傳命中標籤（去重保序）。
func scanSecrets(card string) []string {
	var hits []string
	seen := map[string]bool{}
	for _, line := range strings.Split(card, "\n") {
		if strings.Contains(line, "wiki-secret-ok") {
			continue // 行內豁免（示範格式）
		}
		for _, p := range secretPatterns {
			if seen[p.label] {
				continue
			}
			if p.re.MatchString(line) {
				hits = append(hits, p.label)
				seen[p.label] = true
			}
		}
	}
	return hits
}

// ── 小工具（避免拉進 strconv/fmt 只為兩個轉換）──
func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	neg := n < 0
	if neg {
		n = -n
	}
	var buf [20]byte
	i := len(buf)
	for n > 0 {
		i--
		buf[i] = byte('0' + n%10)
		n /= 10
	}
	if neg {
		i--
		buf[i] = '-'
	}
	return string(buf[i:])
}

// pct 把 0–1 比例轉成百分比整數字串（如 0.4→"40%"）。
func pct(f float64) string { return itoa(int(f*100+0.5)) + "%" }

// trimForMsg 截斷過長的行文於訊息中（避免整段原文塞進回報）。
func trimForMsg(s string) string {
	s = strings.TrimSpace(s)
	if runeLen(s) > 40 {
		return string([]rune(s)[:40]) + "…"
	}
	return s
}
