// scan.go — 掃描迴圈與差異分類（SDD ingest-hash-trigger design §3）。
// 事件順序：先把本輪 removed×added 以 content_hash 配對成 renamed（只更新路徑映射），
// 再分類其餘 added/modified/removed；removed 數 > manifest 條目 × 門檻（預設 40%）
// → removed 全部不執行、改發警告（R6）。本階段不接網路，事件輸出到 stdout。
package collector

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

// 收檔白名單。**注意這只是「收不收」，能不能讀由 convert.go 的 extractors 決定**——
// 兩者要一起看（2026-07-27 t73：`.pdf` 早就在這裡，但 ingest 端擋著＝檔案上了 R2 卻進不了
// 知識庫，使用者看到的是「丟檔進去沒反應」）。
//
// .csv/.xlsx 於 2026-07-27 加入——leo：「要思考 Excel 和 csv 的問題，**因為企業用很多**」。
var allowedExt = map[string]bool{
	".md":       true,
	".markdown": true,
	".txt":      true,
	".docx":     true,
	".pptx":     true,
	".pdf":      true,
	".csv":      true,
	".xlsx":     true,
	// 2026-08-15 InkStoneCo#44 ④：《llm-wiki-作業規範》洞 6 的掃描白名單——
	// .feature（Gherkin 規格）、.yaml/.yml、.org、.rst 都是知識文件（真實 repo 實測
	// 34 個非 md 檔裡 9 個是 .feature）。它們是純文字，走 passthrough（見 IsPlainText）。
	".feature": true,
	".yaml":    true,
	".yml":     true,
	".org":     true,
	".rst":     true,
}

// docLikeExt＝「使用者明顯把它當文件、但我們還讀不了」的副檔名。
//
// 🔴 為什麼要有這張表（J-1/S6 考題 G-6.2，2026-08-06）：
// 下面那道 `!allowedExt[...]` 的閘**直接 return nil**——不進事件、不進 manifest、
// 不進 status、不進畫面。使用者把一份 `.doc` 丟進資料夾，**整個系統從頭到尾一個字都不說**，
// 他只會覺得「這東西壞了」。G-6.2 的判準是：要嘛查得到，**要嘛當場被告知不支援**；
// 「安靜地略過」不是可接受的第三種結果。
//
// 為什麼是白名單、而不是「非 allowedExt 一律點名」：後者在 Obsidian 附件庫（幾百張 .png）
// 或程式碼資料夾裡會炸出幾百行「處理不了」＝噪音，使用者反而學會忽略整塊訊息。
// ⇒ **像文件的逐檔點名，其餘只報一個總數**（見 Scan 的 SkippedOther）。兩種都不沉默，
// 但只有前者值得佔用他的注意力。
//
// 加新格式的順序：先列在這裡（使用者立刻看得到「還不支援」），
// 等 convert.go 真的接上抽取器，再把它從這裡搬去 allowedExt。
// maxOtherNames＝非文件檔最多點名幾個。超過就只報總數（避免幾百張圖洗版）。
const maxOtherNames = 5

var docLikeExt = map[string]bool{
	// 舊版 Office（OLE2 二進位，與 .docx/.xlsx/.pptx 是完全不同的格式）
	".doc": true, ".xls": true, ".ppt": true,
	// Apple iWork
	".pages": true, ".numbers": true, ".key": true,
	// OpenDocument（LibreOffice）
	".odt": true, ".ods": true, ".odp": true,
	// 其他常見文件容器
	".rtf": true, ".epub": true, ".wpd": true, ".msg": true, ".eml": true,
}

// SkippedFile＝這一輪被略過、且值得對使用者逐檔點名的檔案。
//
// ⚠️ 刻意**不寫進 manifest**：它每輪由檔案系統重算，永遠反映現況。
// （對照 t195 的坑：凡是存進 ManifestEntry 的跨輪欄位都得記得在 carry 段補一行，
//
//	漏了就靜默歸零。這裡不建立那份債。）
type SkippedFile struct {
	Path string `json:"path"`
	Ext  string `json:"ext"`
}

// ---- 輸出 payload（對應 schemas/collector-trigger.v1.schema.json）----

type Event struct {
	Type       string `json:"type"`
	Path       string `json:"path"`
	OldPath    string `json:"old_path,omitempty"`
	SourceHash string `json:"source_hash"`
	Size       *int64 `json:"size,omitempty"`
	R2Key      string `json:"r2_key,omitempty"`
}

type Warning struct {
	Code           string  `json:"code"`
	Message        string  `json:"message"`
	RemovedCount   int     `json:"removed_count,omitempty"`
	ManifestCount  int     `json:"manifest_count,omitempty"`
	ThresholdRatio float64 `json:"threshold_ratio,omitempty"`
}

type TriggerPayload struct {
	SchemaVersion int       `json:"schema_version"`
	FolderID      string    `json:"folder_id"`
	Root          string    `json:"root,omitempty"`
	GeneratedAt   int64     `json:"generated_at,omitempty"`
	Events        []Event   `json:"events"`
	Warnings      []Warning `json:"warnings,omitempty"`

	// 🔴 兩個 `json:"-"`（G-6.2，2026-08-06）：被略過的檔案是**給本機使用者看的**，
	// 不是給雲端 ingest 的料。collector-trigger.v1.schema.json 頂層寫死
	// `additionalProperties: false`，多帶一個欄位上線就會被 schema 擋掉
	//（BuildSendablePayload 是 `sendable := *p` 淺拷貝，有 tag 就會一起送出去）。
	// ⇒ 留在記憶體裡，由 direct.go 收進 status.json，給 App 首頁用。
	Skipped      []SkippedFile `json:"-"` // 像文件、但還讀不了的（逐檔點名）
	SkippedOther int           `json:"-"` // 其餘非文件檔（圖片/影音/程式碼…）總數
	// SkippedOtherNames＝上面那些檔的檔名，**最多 maxOtherNames 個**。
	// 🔴 2026-08-06（leo 封測）：封測者放了一個 .md 進去說「無法通過」，而畫面只寫
	//    「有 1 個不是文件的檔案」——**沒說是哪一個**，於是誰也判斷不出發生什麼事
	//    （.md 明明在白名單裡，所以那個 1 一定是別的東西：可能副檔名被 Windows 藏起來、
	//     可能存成了別的格式）。只報總數在「幾百張圖」時是對的，在「1 個」時等於沒說。
	//    ⇒ 少量時就點名，讓使用者自己一眼看出「喔，我存錯格式了」。
	SkippedOtherNames []string `json:"-"`

	// DuplicateFormats＝本輪偵測到、同檔名主幹的多格式重複（2026-08-07，見 FormatDuplicate）。
	// 同 Skipped：只給本機使用者看，不隨 payload 送雲端（schema additionalProperties:false 會擋）。
	DuplicateFormats []FormatDuplicate `json:"-"`

	// Plan／ExcludedByPlan＝這一輪用了什麼收檔策略、據此擋掉幾個檔（arcrun-rag#104）。
	// 同上，`json:"-"`：給本機使用者看的，不送雲端。
	// 🔴 這兩個欄位就是 #104 那條紅線的載體——「排除規則要看得見」。少了它們，
	//    使用者接上一個一萬檔的 repo 只看到 32 個進度，會以為系統壞了。
	Plan           IngestPlan `json:"-"`
	ExcludedByPlan int        `json:"-"`
	// ExcludedDirs／ExcludedDirCount＝整棵被剪掉的目錄與理由（2026-08-16 補）。
	// 🔴 ExcludedByPlan 只數得到「走進去了才被逐檔擋下」的檔；整棵剪掉的子樹
	//    一個都數不到 ⇒ 拿 leo 真實的 pms 跑一輪，2,127 個檔裡絕大多數被排除，
	//    而畫面上的數字是 **0**。講一個 0 跟安靜地少收，對使用者是同一件事。
	ExcludedDirs     []ExcludedDir `json:"-"` // 已排序，上限 MaxExcludedDirsListed
	ExcludedDirCount int           `json:"-"` // 總數（可能大於清單長度）

	// ── InkStoneCo#44 線 A：portal 的資料夾樹（leo 2026-08-17）─────────────────
	// DirStats＝走訪時**逐目錄**數出來的分母（key＝相對監看根的路徑，`""`＝根）。
	// AllExcludedDirs＝整棵被剪掉的目錄，**未裁切**（上面那個 ExcludedDirs 為了畫面
	// 只留 20 筆；樹要畫得完整需要全量，不然「哪些沒收」會缺角）。
	//
	// 🔴 為什麼分母一定要在這裡數、不能另外走一趟：判斷「支不支援／收不收」的那一整套
	// 判準就活在這個 WalkDir 裡（allowedExt／docLikeExt／Plan.KeepsFile／TemplateOwns）。
	// 另外走一趟＝同一件事第二份實作，必然漂移 ⇒ 畫面上的數字與實際收的檔對不起來，
	// 而 leo 要的正是「兩個數字的差額解釋得了」。
	// 同 `json:"-"`：給本機與 portal 用的，不進 collector-trigger schema。
	DirStats        map[string]*dirStat `json:"-"`
	AllExcludedDirs []ExcludedDir       `json:"-"`
}

// dirStat＝一個目錄「這一層直接放的檔案」的分類計數（不含子目錄；子樹合計由畫面自己疊，
// 存兩套就是同一件事兩份實作，遲早對不起來）。
//
// 🔴 不變式：`total == supported + unsupported + excluded`。
// 少一類就會出現「兩個數字的差額解釋不了」，而那正是 leo 這條規格要解掉的病。
type dirStat struct {
	total       int // 這一層看得到的檔案總數（分母）
	supported   int // 通過所有閘、進了 manifest 的（＝分子的候選）
	unsupported int // 副檔名還讀不了的（docLikeExt 與其他）
	excluded    int // 收檔策略／範本身分決定不收的（程式碼多半落這裡）
	// excludeWhy＝這一層的檔被排除的理由（產品文案，第一個排除的檔說了算）。
	// #180：以前不收就是「不走進去」，理由掛在被剪掉的目錄上；現在走進去了，
	// 理由得跟著檔案的排除一起記下來，不然畫面只剩一個沒有解釋的 `0/6`。
	excludeWhy string
}

// MaxExcludedDirsListed：最多逐筆列幾個被跳過的目錄。超過的只反映在 ExcludedDirCount
// （同 MaxSkippedListed 的道理：狀態檔不該被撐成一面看不完的清單牆）。
const MaxExcludedDirsListed = 20

// FormatDuplicate＝同一份內容被偵測到有多種格式並存（同檔名主幹、不同副檔名）。
//
// 🔴 為什麼要有這個（2026-08-07，leo 實據）：封測者 Evan 給的資料集
// 27,164 檔＝9,045 個 md＋9,044 個 json＋9,043 個 html，是同一批來源轉出的三種格式
// （`markdown/160-00F3_001.md` 與 `json/160-00F3_001.json` 逐一對應，同錯誤碼、同變體號）。
// 現行設計不知道這件事，會把三種格式各當一份新檔，萃三次、吃三倍額度——
// 使用者不會知道要挑一種，這是我們該擋的，不是他該懂的。
//
// 判準：檔名主幹（去副檔名、轉小寫）在整個看守根內相同 → 視為同一份內容的不同格式匯出，
// 只留優先序最高的一份進事件管線，其餘標記在這裡（不產生 added/modified/renamed 事件）。
//
// ⚠️ 已知的取捨：純靠檔名主幹比對，不比對內容——兩個不相干的檔恰好同名不同副檔名
// （如兩個專案各自的 `README.md`／`README.pdf`）會被誤判成同一份。刻意接受這個風險，
// 因為：①這不是靜默丟棄——DuplicateFormats 會被 direct.go 收進 status.json 讓使用者看到
// 「跳過：與 X 視為同一格式」，看起來不對可以改檔名破解誤判；②不比對，就是 leo 實據的
// md/json 案例（不同目錄、不同副檔名，唯一共同點正是檔名主幹）根本擋不掉。
//
// 🔴 刻意不寫進 manifest（同 Skipped 的理由，見上方 SkippedFile 註解）：每輪由檔案系統
// 重算，勝出者若之後消失，下一輪換另一份自然遞補，不留跨輪狀態要維護。
type FormatDuplicate struct {
	Path     string `json:"path"`      // 被跳過的那份
	KeptPath string `json:"kept_path"` // 真正進事件管線的那份
	Stem     string `json:"stem"`      // 判定依據：去副檔名、轉小寫後的檔名主幹
}

// dedupFormatPriority：格式去重時「留誰」的優先序（越前面越優先留下）。
// 原則：越接近「使用者原始編輯」的格式排越前面（docx/pptx 是可編輯原稿），
// md/markdown 常是**從別的格式轉出的產物**（leo 實據的資料集正是 html→chm 轉出 md/json），
// 排在轉檔產物之前但在原生辦公格式之後。不在表內的副檔名排最後（理論上不會發生，
// current 只收 allowedExt）。
var dedupFormatPriority = []string{".docx", ".pptx", ".xlsx", ".csv", ".pdf", ".md", ".markdown", ".txt"}

func formatPriority(relPath string) int {
	ext := strings.ToLower(filepath.Ext(relPath))
	for i, e := range dedupFormatPriority {
		if e == ext {
			return i
		}
	}
	return len(dedupFormatPriority)
}

// dedupStemOf 回傳去重判準：檔名主幹（basename 去副檔名），轉小寫（跨平台大小寫不敏感）。
// 刻意只看 basename、不看目錄——leo 實據的 md/json 恰好活在不同的兄弟目錄
// （markdown/160-00F3_001.md vs json/160-00F3_001.json），只有 basename 主幹相同。
func dedupStemOf(relPath string) string {
	base := filepath.Base(relPath)
	stem := strings.TrimSuffix(base, filepath.Ext(base))
	return strings.ToLower(stem)
}

// detectFormatDuplicates 在 current（本輪掃到、通過 allowedExt 的檔）裡找出同檔名主幹的分組，
// 每組留優先序最高的一份，其餘回報為 loser（path -> winner path）。
// 走訪用 stem 字母序＝確定性輸出（map 迭代順序不穩定）。
func detectFormatDuplicates(current map[string]fileState) (map[string]string, []FormatDuplicate) {
	byStem := map[string][]string{}
	for p := range current {
		stem := dedupStemOf(p)
		byStem[stem] = append(byStem[stem], p)
	}
	stems := make([]string, 0, len(byStem))
	for s := range byStem {
		stems = append(stems, s)
	}
	sort.Strings(stems)

	loserOf := map[string]string{}
	var dups []FormatDuplicate
	for _, stem := range stems {
		group := byStem[stem]
		if len(group) < 2 {
			continue
		}
		sort.Slice(group, func(i, j int) bool {
			pi, pj := formatPriority(group[i]), formatPriority(group[j])
			if pi != pj {
				return pi < pj
			}
			return group[i] < group[j] // 同優先序時字母序，確定性
		})
		winner := group[0]
		for _, loser := range group[1:] {
			loserOf[loser] = winner
			dups = append(dups, FormatDuplicate{Path: loser, KeptPath: winner, Stem: stem})
		}
	}
	return loserOf, dups
}

type ScanOptions struct {
	// MaxRemovedRatio：單輪 removed 數 > manifest 條目數 × 本值 → 觸發大量刪除防呆（R6）。
	MaxRemovedRatio float64
	// SkipPaths：絕對路徑黑名單（如 manifest 檔自己住在 root 底下時）。
	SkipPaths map[string]bool
	// SkipDirNames：目錄名黑名單（任一層命中整棵跳過）。daemon-beta task 2：
	// template 代裝後 `system-dev/`（wiki 產物區）不得被當成原稿掃進 ingest。
	SkipDirNames map[string]bool
	// Plan：收檔策略（arcrun-rag#104）。
	//
	// 🔴 2026-08-16 改成「不填就自己算」（Mode == "" ⇒ Scan 自己呼叫 PlanIngest）。
	// 原本的註解寫著「要拿到 #104 的效果就傳 PlanIngest(root)」——也就是**排除規則
	// 要不要生效，取決於呼叫端記不記得傳**。那正是這一票（以及同一天 #88、#46、
	// Arcrun#125）的共同形狀：**能力做好了，而它不在會被執行的那條路上**。
	// 讓走訪器自己裝上判準，「忘了接」這個失敗模式就不存在了。
	// 呼叫端仍可覆寫（測試要造特定情境時照傳即可）。
	Plan IngestPlan
}

const DefaultMaxRemovedRatio = 0.4

type fileState struct {
	hash  string // sha256:<hex>
	size  int64
	mtime int64
}

func hashFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}

func r2KeyOf(sourceHash string) string {
	return "raw/" + strings.TrimPrefix(sourceHash, "sha256:")
}

// Scan 走訪 root、對照並更新 manifest、產出一輪事件。
// manifest 更新原則：content_hash/size/mtime 反映現況；ingested_hash/ingested_at
// 只搬運（renamed）與保留（modified），本函式永不設值——那是上傳成功後的事。
func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
	if opts.MaxRemovedRatio <= 0 {
		opts.MaxRemovedRatio = DefaultMaxRemovedRatio
	}
	// 🔴 呼叫端沒給策略就自己算——見 ScanOptions.Plan 的說明。
	// 這一行就是「排除規則不可能不在執行路徑上」的保證本身。
	if opts.Plan.Mode == "" {
		opts.Plan = PlanIngest(root)
	}
	orig := m.Entries
	manifestCountBefore := len(orig)

	// 1) 走訪檔案系統，建立現況（mtime+size fast-path：沒變→沿用 manifest hash，變了才算 sha256）。
	current := map[string]fileState{}
	var skipped []SkippedFile
	skippedOther := 0
	var skippedOtherNames []string
	// arcrun-rag#104：被策略擋掉的檔案數。**一定要數出來**——票上的紅線是
	// 「用戶要知道有 8,000 個檔沒被收，因為它們是程式碼」，不是安靜地少收。
	excludedByPlan := 0
	// 整棵剪掉的目錄與理由。**剪掉的重點就是不走進去**，所以這裡記的是目錄不是檔案數
	// ——使用者要知道的本來就是「哪幾個資料夾沒收、為什麼」。見 ExcludedDir 的說明。
	var excludedDirs []ExcludedDir
	// InkStoneCo#44 線 A：逐目錄的分母。與上面幾個計數器同一趟走訪算出來——
	// 判準只有這一份，畫面上的數字才可能與實際收的檔對得起來。
	dirStats := map[string]*dirStat{"": {}} // 根一定存在（空資料夾也要有節點，arcrun-rag#106）
	statOf := func(relSlash string) *dirStat {
		dir := folderOfRel(relSlash)
		st, ok := dirStats[dir]
		if !ok {
			st = &dirStat{}
			dirStats[dir] = st
		}
		return st
	}
	err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
		if werr != nil {
			return werr
		}
		name := d.Name()
		relOf := func() (string, bool) {
			rel, rerr := filepath.Rel(root, p)
			if rerr != nil {
				return "", false
			}
			return filepath.ToSlash(rel), true
		}
		if d.IsDir() {
			if p != root && strings.HasPrefix(name, ".") {
				return filepath.SkipDir // 隱藏目錄（.git、.obsidian…）整棵跳過
			}
			if p != root && opts.SkipDirNames[name] {
				return filepath.SkipDir // 名單目錄（呼叫端自訂）整棵跳過
			}
			// #104：依策略整棵剪掉（使用者的 .gitignore／依賴／建置產物／
			// worktree／巢狀 repo）。
			// 🔴 每一次剪枝都要留下「哪一個、為什麼」——票上的紅線是排除規則要看得見，
			//    而 2026-08-16 實測發現原本整棵剪掉的部分完全沒有被記錄。
			//
			// 🔴 inkstone/Arcrun#180（leo 2026-08-28）：**「不收」不准再實作成「不走進去」。**
			//    leo 原話：「我要的就是雲端看到**所有的 folder** 像 tree 一樣呈現，去遍歷就好了」
			//    「所有的 system-dev 都可以展開，**因為就算沒有可萃的它也有下層**」。
			//    實測差距：`youlinhsieh-test1` 地端 `find -type d` 有 13 個目錄，
			//    雲端的樹只有 3 個節點——樹在 `system-dev` 那一節就斷了。
			//    ⇒ 收檔範圍那幾條（curated-wiki 只走 wiki／docs-only 只走文件區／
			//      範本目錄）已從 SkipsDirWhy 搬到 CollectsDirWhy：**走訪照走、節點照生、
			//      數字照算**，只是標明「這一層我不收」。
			if p != root {
				if rel, ok := relOf(); ok {
					if skip, why := opts.Plan.SkipsDirWhy(rel, p); skip {
						excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why})
						return filepath.SkipDir
					}
					// #44 線 A：走得進去的目錄一律登記，**就算它一個檔都沒有**
					// ——leo 的規格是「不管那層有沒有文件，整棵樹都要採」。
					if _, ok := dirStats[rel]; !ok {
						dirStats[rel] = &dirStat{}
					}
					// #180：走進來了，但這一層的檔這次不收 ⇒ 節點照生、子節點照長，
					// 只是把理由掛上去（畫面仍然講得出「為什麼這裡沒收」）。
					if collect, why := opts.Plan.CollectsDirWhy(rel); !collect {
						excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why})
					}
				}
			}
			return nil
		}
		if strings.HasPrefix(name, ".") {
			return nil
		}
		if rel, ok := relOf(); ok && !opts.Plan.KeepsFile(rel) {
			excludedByPlan++
			// #44 線 A：這是「使用者看得到、但我們決定不收」的檔（程式碼多半落在這裡）。
			// 它**要算進分母**——leo 的規格就是「兩個數字不相等是正常的，差額＝不支援」，
			// 而分母裡沒有它，差額就永遠解釋不了。
			s := statOf(rel)
			s.total++
			s.excluded++
			if s.excludeWhy == "" {
				if _, why := opts.Plan.CollectsDirWhy(folderOfRel(rel)); why != "" {
					s.excludeWhy = why
				} else {
					s.excludeWhy = "這一層的檔案不在這次的收檔範圍裡"
				}
			}
			return nil
		}
		if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
			return nil
		}
		// 🔴 2026-08-06 leo：template 的東西不是使用者的知識，一律不收。
		//    **用路徑身分認，不用「有沒有以點開頭」認**——leo 自己的 repo 裡
		//    template 本來就不是隱藏的，靠隱藏判斷會漏掉一大半。
		//    也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的，
		//    我們自己鋪的東西不該佔用他的注意力。
		//
		// 🔴 arcrun-rag#104 例外：curated-wiki 模式下，`system-dev/wiki/` **正是要收的那一份**。
		//    上面那條規則（2026-08-06）與 leo 2026-08-14 的規格直接對撞，衝突由身分化解：
		//    · 我們代裝 template 的資料夾（沒有 `.git`）⇒ 那些檔是**我們鋪的**，照舊不收
		//    · 使用者自己的 repo（有 `.git`）⇒ 那份 wiki 是**他寫的**，正是他要我們讀的
		//    判準只有 PlanIngest 一個地方，見 ingestplan.go。
		//
		// 🔴 inkstone/Arcrun#180：**不收照舊，但不准再從畫面上消失。**
		//    leo 要的是「知道資料夾下有幾個檔案被萃」——以前這裡直接 `return nil`，
		//    那些檔連分母都沒進去，於是 `system-dev/wiki`（實際 6 個檔）在樹上是 `0/0`、
		//    `scripts`（實際 1 個檔）也是 `0/0`。**兩個 0 都是我們自己編的數字。**
		//    現在照樣不收，但算進 total／excluded，使用者看得到「這裡有 6 個，我一個都沒收」。
		//
		// 🔴 inkstone/Arcrun#180 驗收條件 2／3：判準從**路徑前綴**換成**內容**
		//    （templateuntouched.go）。舊的 `TemplateOwns` 把 `system-dev/` 整棵當範本，
		//    分不出同一棵樹底下的兩種東西——`system-dev/wiki/status.md`（安裝器鋪的空殼）
		//    與 `system-dev/wiki/cards/*.md`（9 張真的卡）於是一起被丟掉。
		//    現在逐檔比對：和快照逐字相同＝空殼，不收；其餘照常判斷。
		if rel, rerr := filepath.Rel(root, p); rerr == nil {
			relSlash := filepath.ToSlash(rel)
			if TemplateUntouched(relSlash, p) {
				s := statOf(relSlash)
				s.total++
				s.excluded++
				if s.excludeWhy == "" {
					s.excludeWhy = "這是安裝範本時鋪下來的空白樣板，你還沒在上面寫過東西"
				}
				return nil
			}
		}
		ext := strings.ToLower(filepath.Ext(name))
		// #44 線 A：走到這裡的都是「使用者自己的、看得見的」檔（隱藏檔與 SkipPaths 的
		// 機器檔刻意不算進分母：**我們自己寫的東西不該佔用他的注意力**）。
		if rel, ok := relOf(); ok {
			s := statOf(rel)
			s.total++
			if allowedExt[ext] {
				s.supported++
			} else {
				s.unsupported++
			}
		}
		if !allowedExt[ext] {
			// G-6.2：**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
			// 現在留個名，讓 direct.go 有東西可以寫進 status.json、App 有東西可以顯示。
			if docLikeExt[ext] {
				if rel, rerr := filepath.Rel(root, p); rerr == nil {
					skipped = append(skipped, SkippedFile{Path: filepath.ToSlash(rel), Ext: ext})
				}
			} else {
				skippedOther++
				// 只留前幾個：多了就變噪音（Obsidian 附件庫可能有幾百張 .png）。
				if len(skippedOtherNames) < maxOtherNames {
					if rel, rerr := filepath.Rel(root, p); rerr == nil {
						skippedOtherNames = append(skippedOtherNames, filepath.ToSlash(rel))
					}
				}
			}
			return nil
		}
		info, ierr := d.Info()
		if ierr != nil {
			return ierr
		}
		rel, rerr := filepath.Rel(root, p)
		if rerr != nil {
			return rerr
		}
		rel = filepath.ToSlash(rel)
		st := fileState{size: info.Size(), mtime: info.ModTime().Unix()}
		if e, ok := orig[rel]; ok && e.ContentHash != "" && e.Mtime == st.mtime && e.Size == st.size {
			st.hash = e.ContentHash // fast-path：mtime+size 沒變，跳過重算
		} else {
			h, herr := hashFile(p)
			if herr != nil {
				return herr
			}
			st.hash = h
		}
		current[rel] = st
		return nil
	})
	if err != nil {
		return nil, err
	}

	// 1.5) 同內容多格式去重（2026-08-07）：先決定誰是 loser，事件管線全程跳過它們。
	dupLoser, duplicateFormats := detectFormatDuplicates(current)

	// 2) 初分：added 候選（現況有、manifest 無）與 removed 候選（manifest 有、現況無）。
	//    loser 不進候選——它不該被當成新檔，也不該被當成 rename 的另一端。
	var addedPaths, removedPaths []string
	for p := range current {
		if dupLoser[p] != "" {
			continue
		}
		if _, ok := orig[p]; !ok {
			addedPaths = append(addedPaths, p)
		}
	}
	for p := range orig {
		if _, ok := current[p]; !ok {
			removedPaths = append(removedPaths, p)
		}
	}
	sort.Strings(addedPaths)
	sort.Strings(removedPaths)

	// 3) 先配對 renamed（design §3 順序 1）：removed×added 以 content_hash 配對，
	//    配上＝只更新路徑映射，不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對（確定性）。
	removedByHash := map[string][]string{}
	for _, p := range removedPaths {
		h := orig[p].ContentHash
		removedByHash[h] = append(removedByHash[h], p)
	}
	renamedOldOf := map[string]string{} // newPath -> oldPath
	pairedOld := map[string]bool{}
	var events []Event
	for _, np := range addedPaths {
		h := current[np].hash
		cands := removedByHash[h]
		if len(cands) == 0 {
			continue
		}
		op := cands[0]
		removedByHash[h] = cands[1:]
		pairedOld[op] = true
		renamedOldOf[np] = op
		events = append(events, Event{Type: "renamed", Path: np, OldPath: op, SourceHash: h})
	}

	// 4) added：真新檔＋「曾偵測但從未成功 ingest」的檔（重試語意，design §2）。
	sortedCurrent := make([]string, 0, len(current))
	for p := range current {
		sortedCurrent = append(sortedCurrent, p)
	}
	sort.Strings(sortedCurrent)
	addedEvent := func(p string) Event {
		st := current[p]
		size := st.size
		return Event{Type: "added", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)}
	}
	for _, p := range sortedCurrent {
		if dupLoser[p] != "" { // 同內容的另一格式已在事件管線，這份跳過（1.5）
			continue
		}
		if op, isRenamed := renamedOldOf[p]; isRenamed {
			if orig[op].IngestedHash == "" { // 改名的檔其實從未 ingest 成功 → 補一發 added
				events = append(events, addedEvent(p))
			}
			continue
		}
		if _, existed := orig[p]; !existed {
			events = append(events, addedEvent(p)) // 真新檔
		} else if orig[p].IngestedHash == "" {
			events = append(events, addedEvent(p)) // 上輪偵測過但 ingest 未成功 → 重試
		}
	}

	// 5) modified：manifest 有、現況有、content_hash != ingested_hash（design §3 順序 3）。
	for _, p := range sortedCurrent {
		if dupLoser[p] != "" { // 同內容的另一格式已在事件管線，這份跳過（1.5）
			continue
		}
		e, existed := orig[p]
		if !existed {
			continue
		}
		if _, isRenamed := renamedOldOf[p]; isRenamed {
			continue
		}
		if e.IngestedHash != "" && current[p].hash != e.IngestedHash {
			st := current[p]
			size := st.size
			events = append(events, Event{Type: "modified", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)})
		}
	}

	// 6) removed（扣掉已配對走的）＋大量刪除防呆（R6）。
	var finalRemoved []string
	for _, p := range removedPaths {
		if !pairedOld[p] {
			finalRemoved = append(finalRemoved, p)
		}
	}
	var warnings []Warning
	guardTripped := manifestCountBefore > 0 &&
		float64(len(finalRemoved)) > opts.MaxRemovedRatio*float64(manifestCountBefore)
	if guardTripped {
		warnings = append(warnings, Warning{
			Code: "mass_delete_guard",
			Message: fmt.Sprintf(
				"本輪偵測到 %d/%d 個檔案消失（超過 %.0f%% 門檻）——可能是資料夾未掛載或同步半途。本輪全部「不」下架，請確認資料夾完好後再放行。",
				len(finalRemoved), manifestCountBefore, opts.MaxRemovedRatio*100),
			RemovedCount:   len(finalRemoved),
			ManifestCount:  manifestCountBefore,
			ThresholdRatio: opts.MaxRemovedRatio,
		})
	} else {
		for _, p := range finalRemoved {
			events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash})
		}
	}

	// 7) 更新 manifest（rebuild）：現況檔全數收錄；ingested_* 由舊 entry（或 renamed 的舊路徑）搬運。
	//    防呆觸發時 removed 條目保留（下輪重評、警告會再響，直到人確認或檔案回來）。
	newEntries := make(map[string]*ManifestEntry, len(current))
	for p, st := range current {
		ne := &ManifestEntry{ContentHash: st.hash, Size: st.size, Mtime: st.mtime}
		var carry *ManifestEntry
		if op, isRenamed := renamedOldOf[p]; isRenamed {
			carry = orig[op]
		} else if e, ok := orig[p]; ok {
			carry = e
		}
		if carry != nil {
			ne.IngestedHash = carry.IngestedHash
			ne.IngestedAt = carry.IngestedAt
			// 🔴 t195：掃描每輪都**重建** entry，原本只 carry 上面兩欄 ⇒ 其餘欄位靜默歸零。
			//   實撞：失敗退避（fail_count/next_retry）寫進去了，下一輪掃描卻被抹掉
			//   ⇒ 退避永遠停在「第 1 次失敗」，等同沒有退避（1387 輪的病根之一）。
			//   ExtractedBy（t73 記的「誰萃的」）原本也一樣悄悄丟失。
			//   ⚠️ 之後在 ManifestEntry 新增任何「跨輪要記住」的欄位，都必須加在這裡。
			ne.ExtractedBy = carry.ExtractedBy
			ne.FailCount = carry.FailCount
			ne.LastFailAt = carry.LastFailAt
			ne.NextRetry = carry.NextRetry
			// 🔴 2026-08-08（Evan 封測回報「20 份沒送進知識庫」但展開看不到真因）：
			//   LastError 是 t195 同一批加的欄位，卻**漏在這個 carry 裡** ⇒
			//   每輪掃描（預設 5 秒）就把失敗真因抹掉一次，下一輪退避訊息組不出「｜原因：」
			//   ⇒ 畫面只剩 humanizeFailure 的最後退路「當時沒有記下原因」。
			//   這正是上面那句警告的第二次實例——真因是我們自己刪掉的，不是沒記。
			ne.LastError = carry.LastError
			// 🔴 #140（2026-08-26）：雲端對帳的三個欄位同樣要 carry，而且漏了會**很貴**：
			//   - CloudCheckedAt 歸零 ⇒ 每輪都判「太久沒對帳」⇒ 每輪重問整批（請求風暴）
			//   - CloudMissingAt 歸零 ⇒ **防重送迴圈的 grace 消失** ⇒ 補送 → 下一輪又拔章
			//     → 再補送…把使用者的 AI 額度燒光（票上明文禁止的「把一個 bug 換成另一個」）
			//   - NoCloudCard 歸零 ⇒ 沒卡可送的檔每天被重萃一次，永遠停不下來
			//   這正是上面那句警告（t195／LastError 5fcc139）的第三次實例，所以照著它走。
			ne.CloudCheckedAt = carry.CloudCheckedAt
			ne.CloudMissingAt = carry.CloudMissingAt
			ne.NoCloudCard = carry.NoCloudCard
		}
		newEntries[p] = ne
	}
	if guardTripped {
		for _, p := range finalRemoved {
			newEntries[p] = orig[p]
		}
	}
	m.Entries = newEntries
	m.Root = root

	if events == nil {
		events = []Event{}
	}
	sort.Slice(skipped, func(i, j int) bool { return skipped[i].Path < skipped[j].Path })
	// 排序＝畫面每輪穩定；裁切前先記總數，不然「等 N 個」會少報。
	sort.Slice(excludedDirs, func(i, j int) bool { return excludedDirs[i].Path < excludedDirs[j].Path })
	excludedDirCount := len(excludedDirs)
	// #44 線 A：樹要畫得完整，需要**未裁切**的全量（下面那個裁切是給狀態列用的）。
	// 先複製再裁切——共用同一個底層陣列的話，裁切會讓樹跟著少一截。
	allExcludedDirs := make([]ExcludedDir, len(excludedDirs))
	copy(allExcludedDirs, excludedDirs)
	if len(excludedDirs) > MaxExcludedDirsListed {
		excludedDirs = excludedDirs[:MaxExcludedDirsListed]
	}
	return &TriggerPayload{
		SchemaVersion:     1,
		FolderID:          m.FolderID,
		Root:              root,
		GeneratedAt:       time.Now().Unix(),
		Events:            events,
		Warnings:          warnings,
		Skipped:           skipped,
		SkippedOther:      skippedOther,
		SkippedOtherNames: skippedOtherNames,
		DuplicateFormats:  duplicateFormats,
		Plan:              opts.Plan,
		ExcludedByPlan:    excludedByPlan,
		ExcludedDirs:      excludedDirs,
		ExcludedDirCount:  excludedDirCount,
		DirStats:          dirStats,
		AllExcludedDirs:   allExcludedDirs,
	}, nil
}
