// tidy.go — 認出並收拾「daemon 以前寫進使用者資料夾、但還沒帶標記」的舊產物
// （arcrun-rag#60 第二輪驗收條件③）。
//
// 🔴 為什麼光「以後不再撞名」不夠：
//
//	leo 打開資料夾的當下，裡面已經有一批舊產物了（`status.md`／`mistakes.md`／
//	日期檔那些）。修好未來不會讓現在變乾淨——**他抱怨的是他現在看到的東西**。
//	所以這一輪必須同時交出「一次認出來／一次改名」的辦法。
//
// 🔴 這是別人的資料，所以本檔只做兩件可回收的事：**改名**與**搬移**。
//
//	一行 os.Remove 都沒有。誤判時他把檔案搬回去就好，不會有東西不見。
//	同理，目標檔名已經存在時一律**跳過並報告**，不覆蓋（safewrite.go 同一條紅線）。
//
// 兩個入口，責任不同：
//
//	① MigrateCardNames()：daemon 每輪自動跑，只碰**卡片產物區**（那整個目錄是我們寫的，
//	   百分之百不會誤傷）。leo 不必知道它存在——「不該為了保護自己的筆記去學新選項」。
//	② Tidy() / `collector tidy`：人工跑，多處理一類——**template 殘留**。
//	   那一類不能自動搬，因為同樣那幾個路徑在「leo 自己的開發 repo」裡是**他的真檔案**
//	   （TemplateOwns 是路徑身分判準，分不出「daemon 鋪的」還是「repo 本來就有的」）。
//	   ⇒ 只有在**確定是 vault**（沒有人會把 system-dev template 放進 Logseq 筆記庫）
//	     時才動手，其餘一律只列出來讓人自己決定。
package collector

import (
	"encoding/json"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
)

// 產物分類（報告用；也是「動不動手」的判準）。
const (
	TidyKindCard     = "card"              // 卡片本體
	TidyKindCardBak  = "card-backup"       // safeWriteCard 產生的 .bak-<unixnano>
	TidyKindTemplate = "template-leftover" // 舊版 daemon 代裝的 system-dev template 殘留
)

// 動作（報告用）。dry-run 時只會出現 would-* 與 report-only。
const (
	TidyActionRenamed    = "renamed"
	TidyActionMoved      = "moved"
	TidyActionWillRename = "would-rename"
	TidyActionWillMove   = "would-move"
	TidyActionReport     = "report-only" // 我不動它，請人自己判斷
	TidyActionSkipped    = "skipped"     // 想動但不能動（目標已存在／搬不動）
)

// legacyTemplateRelDir 是 template 殘留的收容處：監看根底下的隱藏目錄，
// 保留原本的相對路徑結構（`.arcrun-rag/legacy-template/system-dev/wiki/status.md`）。
// 刻意留在同一個資料夾裡而不是丟到 ~/ 或垃圾桶——東西還在他手上，要還原只是搬回去。
const legacyTemplateRelDir = ".arcrun-rag/legacy-template"

// bakSuffix 認 safeWriteCard 的備份檔：`<原名>.bak-<unixnano>`。
var bakSuffix = regexp.MustCompile(`\.bak-\d+$`)

// TidyItem＝一筆產物與我們對它做了（或會做）什麼。
type TidyItem struct {
	Rel    string `json:"rel"`            // 相對監看根的路徑（斜線分隔）
	To     string `json:"to,omitempty"`   // 改名/搬移的目的地（同樣相對監看根）
	Kind   string `json:"kind"`           // TidyKind*
	Action string `json:"action"`         // TidyAction*
	Note   string `json:"note,omitempty"` // 沒動它的原因（白話）
}

// TidyReport＝一次收拾的完整帳目。
type TidyReport struct {
	Root      string    `json:"root"`
	VaultType VaultType `json:"vault_type"`
	// VaultRoot＝筆記庫的根。與 Root 不同時代表「監看的是筆記庫底下的一層子資料夾」
	// ——第三輪修的正是這一種（arcrun-rag#60）。空字串＝不在任何筆記庫裡。
	VaultRoot string     `json:"vault_root,omitempty"`
	Applied   bool       `json:"applied"` // false＝只看不動（dry-run）
	Items     []TidyItem `json:"items"`
}

// Counts 依 action 統計，給人看的一行摘要用。
func (r *TidyReport) Counts() map[string]int {
	out := map[string]int{}
	for _, it := range r.Items {
		out[it.Action]++
	}
	return out
}

// cardDirsToScan 回傳「卡片可能住過的所有目錄」（相對監看根）。
// 刻意兩個都掃，不看目前是不是 vault——舊產物可能是在資料夾還沒變成 vault 之前落下的，
// 也可能是第一輪修復前落在 system-dev/wiki/cards/ 的。認人要認全，不能只認現在這一種。
func cardDirsToScan() []string {
	return []string{cardsRelDir, vaultCardsRelDir}
}

// isUnderCardDir 回答「這個相對路徑是不是住在卡片產物區底下」。
func isUnderCardDir(relSlash string) bool {
	for _, d := range cardDirsToScan() {
		if strings.HasPrefix(relSlash, d+"/") {
			return true
		}
	}
	return false
}

// collectCardItems 掃出卡片產物區裡「不在它現在該在的位置／還沒帶標記」的檔案，
// 算出它們該搬去哪、該叫什麼名字。已經到位又帶標記的不列入（冪等：跑第二次是空的）。
//
// 🔴 arcrun-rag#60 第三輪：判準從「有沒有帶標記」擴充成「**位置對不對** ＋ 有沒有帶標記」。
// 只看標記在第三輪不夠——監看根是 vault 子資料夾時，前兩輪的 daemon 已經把一批
// **帶標記但落在可見目錄**（`system-dev/wiki/cards/arcrun-*.md`）的卡寫進使用者的
// 筆記庫了。那些卡在 Logseq 眼裡照樣是一頁一頁的機器頁面，改名救不了，要搬進隱藏目錄。
// 少了這一段，票上「已經寫進去的那一批要能收拾」對這一類就等於沒做。
func collectCardItems(absRoot string) []TidyItem {
	want := cardsRelDirFor(absRoot)
	var items []TidyItem
	for _, relDir := range cardDirsToScan() {
		base := filepath.Join(absRoot, filepath.FromSlash(relDir))
		_ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
			if err != nil {
				return nil
			}
			// 🔴 inkstone/Arcrun#134（2026-08-28 實測）：**隱藏目錄整棵跳過。**
			//
			// `<node>/.wiki/` 是規範定的卡片正式落點（machinemark.go 例外 ③），
			// 而卡片產物區 `system-dev/wiki/cards/` 底下也會長出一個。少了這一行，
			// 這支就把 `cards/.wiki/換柱.md`「歸位」成 `cards/arcrun-換柱.md`
			// ——把**產物搬回 Scan 看得見的地方**，於是下一輪它被當成新原稿再萃一次，
			// 產出「卡片的卡片」，再被搬出來⋯⋯迴圈不會停。
			// 實測 `youlinhsieh-test1`：`cards/` 20 分鐘內從 9 個檔長到 55 個，
			// 出現 `arcrun-換柱（arcrun-換柱）.md` 這種第三代檔名。
			//
			// `collectTemplateItems` 同一輪（f71e5a2）就寫了同一行同一個理由
			// （「收容處自己就在 `.arcrun-rag/` 底下，不跳過的話第二次跑會把
			// 搬過去的東西再搬一次」）——這支漏掉了。當時 `.wiki/` 還不存在
			// （2026-08-15 才隨 ef5e6c5 進來），所以不是被否決，是沒人回來補。
			//
			// 📌 `inkstone/Arcrun#180` 從**另一面**撞到同一個洞，兩件事一起記著：
			//   · #134 看到的是「產物被搬出去 ⇒ 下一輪再萃 ⇒ 卡萃卡」
			//   · #180 看到的是「萃取端**讀不到自己一秒前寫的卡**」——
			//     8 份文件全部 `讀卡片失敗：open …/.wiki/結晶管線.md: no such file`，
			//     於是 `system-dev/wiki/cards/` 底下那 9 張現成的卡一張都上不了雲端。
			//   ⇒ 同一行修好兩個症狀。
			// ⚠️ 只跳**子**目錄（`p != base`）：`.arcrun-rag/wiki/cards` 是 base 自己，
			//    #60 第三輪正是把卡搬進那裡——連 base 一起跳掉等於把上一輪的修復關掉。
			//    `TestMigrateCardNames_收容處自己仍然掃得到` 守這一格。
			if d.IsDir() {
				if p != base && strings.HasPrefix(d.Name(), ".") {
					return filepath.SkipDir
				}
				return nil
			}
			name := d.Name()
			rel, rerr := filepath.Rel(absRoot, p)
			if rerr != nil {
				return nil
			}
			relSlash := filepath.ToSlash(rel)
			if IsMarked(name) && filepath.ToSlash(filepath.Dir(relSlash)) == want {
				return nil // 位置對、標記也有，已經是新的了
			}
			kind := TidyKindCard
			if bakSuffix.MatchString(name) {
				kind = TidyKindCardBak
			}
			items = append(items, TidyItem{
				Rel:  relSlash,
				To:   filepath.ToSlash(filepath.Join(want, MarkName(name))),
				Kind: kind,
			})
			return nil
		})
	}
	return items
}

// collectTemplateItems 掃出 template 殘留（TemplateOwns 命中的路徑）。
//
// vault＝可以搬（沒人會把開發用 template 放進筆記庫，判定為 daemon 舊版鋪的）。
// 非 vault＝**只列不動**：那可能正是使用者自己的開發 repo，搬了會把他的 repo 弄壞。
func collectTemplateItems(absRoot string, isVault bool) []TidyItem {
	var items []TidyItem
	_ = filepath.WalkDir(absRoot, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return nil
		}
		if d.IsDir() {
			// 隱藏目錄整棵跳過——收容處自己就在 .arcrun-rag/ 底下，
			// 不跳過的話第二次跑會把搬過去的東西再搬一次。
			if p != absRoot && strings.HasPrefix(d.Name(), ".") {
				return filepath.SkipDir
			}
			return nil
		}
		rel, rerr := filepath.Rel(absRoot, p)
		if rerr != nil {
			return nil
		}
		relSlash := filepath.ToSlash(rel)
		if !TemplateOwns(relSlash) {
			return nil
		}
		// 🔴 卡片產物區就住在 `system-dev/wiki/cards/` 底下，而 TemplateOwns 是**目錄前綴**
		// 判準（`system-dev/` 整棵都算 template）⇒ 不排除的話，同一張卡會同時被認成
		// 「卡片（該改名）」與「template 殘留（該搬走）」，兩個動作搶同一個檔，
		// 先跑的改完名、後跑的就 Stat 不到 ⇒ 報告出現假的失敗。卡片一律由 collectCardItems 管。
		if isUnderCardDir(relSlash) {
			return nil
		}
		it := TidyItem{Rel: relSlash, Kind: TidyKindTemplate}
		if isVault {
			it.To = filepath.ToSlash(filepath.Join(legacyTemplateRelDir, relSlash))
		} else {
			it.Action = TidyActionReport
			it.Note = "這不是 vault，可能是你自己的開發 repo（template 本來就該在）——我不動它，要清請自己確認"
		}
		items = append(items, it)
		return nil
	})
	return items
}

// CardMigration＝MigrateCardNames 一輪的帳目。
//
// Blocked 不是「失敗」，是**刻意沒動**：那個資料夾在版控裡，我們沒有資格自己決定
// 改別人版控中的檔案。它要被講出來（direct.go 收進 results ⇒ status.json ⇒ App），
// 不是靜默跳過——#105 的紅線與 #104 同一條：「不要讓用戶猜」。
type CardMigration struct {
	Moved    int    // 真的改名/搬移了幾個
	Blocked  int    // 認出是舊卡、但因為在版控裡而沒動的
	RepoRoot string // Blocked > 0 時：那個版控工作目錄的根
}

// MigrateCardNames 把卡片產物區裡「位置不對或沒帶標記」的舊卡歸位。daemon 每輪自動呼叫。
// **只碰卡片產物區**，其餘一概不動。
//
// 🔴 第四輪＝arcrun-rag#105（2026-08-14 實撞）：**版控中的資料夾一個檔都不動。**
//
//	前三輪（#60，f71e5a2／578ac2b）修的都是同一個場景的深度——「卡片會不會汙染
//	使用者的**筆記庫**」，方向一路往「更自動」走，因為那條紅線是「不准要使用者
//	去設定什麼開關才能保護自己的筆記」。**本輪不推翻那個方向**：非版控資料夾
//	（含 vault）的行為與 578ac2b 完全一致。
//
//	本輪修的是另一個場景——「監看的是他的 **git repo**」。上一版這裡的理由是
//	「那兩個目錄從頭到尾只有 daemon 會寫，不存在誤把使用者的檔案改名的可能」。
//	那句話在 repo 上錯了，而且錯得很貴：`system-dev/wiki/cards/` 是
//	system-dev-template 的規約路徑，而 template 就是要裝進**開發者自己的 repo**——
//	leo 的 `InkStoneCo` 裡那個目錄底下是他自己寫的知識卡（還有 `autonomy/` 這種
//	他自己開的子目錄）。daemon 把它們壓平改名，**16 個版控中的檔案當場變成刪除**。
//
//	⇒ 判準加一問「這裡有沒有 `.git`」（見 repoguard.go 對「這夠不夠」的討論）。
//	有＝停手、記帳、講給使用者聽，要整理請他自己跑 `collector tidy --apply`
//	（那條路預設 dry-run，會先把要動什麼列給他看）。
//	兩條紅線不衝突：「不要他學新選項才能保護筆記」管的是筆記庫；
//	repo 那邊 leo 的原話是「**預設應該是不動，要動要有出口**」。
func MigrateCardNames(absRoot string) CardMigration {
	items := collectCardItems(absRoot)
	if repoRoot := DetectRepoRoot(absRoot); repoRoot != "" {
		// 只把**確定是我們寫的**（帶 arcrun- 標記、只是位置不對）算進 Blocked。
		// 沒帶標記的在 repo 裡預設就是使用者自己的檔案——把它們算進來，訊息就會變成
		// 「有 40 個舊卡片沒整理」，而那 40 張是 leo 自己寫的知識卡，
		// 報一個全錯的數字比不報更糟（#105 要的是誠實，不是熱心）。
		blocked := 0
		for _, it := range items {
			if IsMarked(filepath.Base(it.Rel)) {
				blocked++
			}
		}
		return CardMigration{Blocked: blocked, RepoRoot: repoRoot}
	}
	out := CardMigration{}
	for _, it := range items {
		from := filepath.Join(absRoot, filepath.FromSlash(it.Rel))
		to := filepath.Join(absRoot, filepath.FromSlash(it.To))
		if _, err := os.Stat(to); err == nil {
			continue // 新名字已經有東西了，不覆蓋
		}
		if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
			continue
		}
		if err := os.Rename(from, to); err == nil {
			out.Moved++
		}
	}
	return out
}

// Tidy 掃出所有舊產物；apply=false 只報告，apply=true 才真的改名/搬移。
func Tidy(root string, apply bool) (*TidyReport, error) {
	absRoot, err := filepath.Abs(root)
	if err != nil {
		return nil, err
	}
	info, err := os.Stat(absRoot)
	if err != nil {
		return nil, fmt.Errorf("看不到這個資料夾（%s）：%w", absRoot, err)
	}
	if !info.IsDir() {
		return nil, fmt.Errorf("%s 不是資料夾", absRoot)
	}

	// 🔴 兩個判準，回答兩個不同的問題，不要合併（arcrun-rag#60 第三輪）：
	//
	//	· ctx（含往上找）＝「我寫東西會不會落進誰的筆記庫」——決定卡片該在哪，也是報告顯示的身分。
	//	· DetectVaultType(absRoot)（只看這一層）＝「這個資料夾**本身**是不是筆記庫」——
	//	  只有它才夠格授權「把 template 殘留搬走」。理由：一個住在筆記庫底下的
	//	  **使用者自己的開發 repo**（`KB/some-repo/system-dev/…`）在 ctx 眼中也「在 vault 裡」，
	//	  但那些 template 檔是他的真檔案，不是 daemon 鋪的。搬了就是弄壞他的 repo。
	//	  分不出來的時候只列不動——這條在第二輪就寫了，第三輪擴大偵測範圍時更要守住。
	ctx := DetectVaultContext(absRoot)
	rep := &TidyReport{Root: absRoot, VaultType: ctx.Type, VaultRoot: ctx.Root, Applied: apply}

	items := collectCardItems(absRoot)
	items = append(items, collectTemplateItems(absRoot, DetectVaultType(absRoot) != VaultNone)...)
	sort.Slice(items, func(i, j int) bool { return items[i].Rel < items[j].Rel })

	for _, it := range items {
		if it.Action == TidyActionReport { // 已經判定只列不動
			rep.Items = append(rep.Items, it)
			continue
		}
		// 換了目錄就是「搬走」，只換名字才是「改名」——報告要說實話，
		// 使用者才知道去哪裡找他的東西（第三輪起卡片也會換目錄）。
		willMove := it.Kind == TidyKindTemplate ||
			filepath.ToSlash(filepath.Dir(it.Rel)) != filepath.ToSlash(filepath.Dir(it.To))
		from := filepath.Join(absRoot, filepath.FromSlash(it.Rel))
		to := filepath.Join(absRoot, filepath.FromSlash(it.To))

		if _, serr := os.Stat(to); serr == nil {
			it.Action = TidyActionSkipped
			it.Note = "目的地已經有同名檔案了，我不覆蓋——請自己看一眼哪一份要留"
			rep.Items = append(rep.Items, it)
			continue
		}
		if !apply {
			it.Action = TidyActionWillRename
			if willMove {
				it.Action = TidyActionWillMove
			}
			rep.Items = append(rep.Items, it)
			continue
		}
		if merr := os.MkdirAll(filepath.Dir(to), 0o755); merr != nil {
			it.Action, it.Note = TidyActionSkipped, "建目的地資料夾失敗："+merr.Error()
			rep.Items = append(rep.Items, it)
			continue
		}
		if rerr := os.Rename(from, to); rerr != nil {
			it.Action, it.Note = TidyActionSkipped, "搬不動："+rerr.Error()
			rep.Items = append(rep.Items, it)
			continue
		}
		it.Action = TidyActionRenamed
		if willMove {
			it.Action = TidyActionMoved
		}
		rep.Items = append(rep.Items, it)
	}
	return rep, nil
}

// runTidy 是 `collector tidy` 子命令主體。
// 預設**只看不動**（要真的動手得自己加 --apply）——別人的資料夾，預設值就該是安全的那一邊。
func runTidy(args []string) int {
	fs2 := newFlagSet()
	folder := fs2.String("folder", "", "要收拾的資料夾（必填，就是 daemon 在看守的那個）")
	apply := fs2.Bool("apply", false, "真的動手改名/搬移（不加＝只列出會動到什麼）")
	asJSON := fs2.Bool("json", false, "輸出 JSON（給程式讀；不加＝給人看的清單）")
	if err := fs2.Parse(args); err != nil {
		return 2
	}
	if *folder == "" {
		fmt.Fprintln(os.Stderr, "錯誤：--folder 為必填")
		return 2
	}
	rep, err := Tidy(*folder, *apply)
	if err != nil {
		fmt.Fprintln(os.Stderr, "collector tidy:", err)
		return 1
	}
	if *asJSON {
		data, _ := json.MarshalIndent(rep, "", "  ")
		fmt.Println(string(data))
		return 0
	}

	if len(rep.Items) == 0 {
		fmt.Printf("%s：沒有找到任何沒帶標記的舊產物，這個資料夾是乾淨的。\n", rep.Root)
		return 0
	}
	kind := "一般資料夾"
	switch {
	case rep.VaultType != VaultNone && rep.VaultRoot != rep.Root:
		// 第三輪的那一種擺法——講清楚是誰的庫，不然使用者看不懂為什麼要搬。
		kind = fmt.Sprintf("%s 筆記庫「%s」底下的子資料夾", rep.VaultType, rep.VaultRoot)
	case rep.VaultType != VaultNone:
		kind = string(rep.VaultType) + " 筆記庫"
	}
	fmt.Printf("%s（%s）\n", rep.Root, kind)
	if !rep.Applied {
		fmt.Println("※ 這只是預覽，什麼都還沒動。確認清單沒問題後，同一行指令加上 --apply 才會真的執行。")
	}
	for _, it := range rep.Items {
		switch it.Action {
		case TidyActionRenamed, TidyActionWillRename:
			fmt.Printf("  改名  %s  →  %s\n", it.Rel, it.To)
		case TidyActionMoved, TidyActionWillMove:
			why := "舊版 daemon 鋪的開發用檔案，不是你的筆記"
			if it.Kind != TidyKindTemplate {
				why = "舊版寫在看得見的位置，你的筆記軟體會把它當成一頁——搬進隱藏目錄"
			}
			fmt.Printf("  搬走  %s  →  %s（%s）\n", it.Rel, it.To, why)
		case TidyActionReport:
			fmt.Printf("  略過  %s（%s）\n", it.Rel, it.Note)
		case TidyActionSkipped:
			fmt.Printf("  沒動  %s（%s）\n", it.Rel, it.Note)
		}
	}
	c := rep.Counts()
	fmt.Printf("合計：改名 %d、搬走 %d、只列出 %d、沒動 %d\n",
		c[TidyActionRenamed]+c[TidyActionWillRename],
		c[TidyActionMoved]+c[TidyActionWillMove],
		c[TidyActionReport], c[TidyActionSkipped])
	fmt.Println("（本指令從不刪除任何東西：只有改名與搬移，覺得不對就自己搬回去。）")
	return 0
}
