// folderindex.go — 讓目錄索引變成**碎形**：每一層資料夾各有自己的一格，父子接得起來
// （`inkstone/Arcrun#146`，leo 2026-08-19）。
//
// leo 的原話（兩句，就是本檔的全部規格）：
//
//	「daemon 可以**碎形**的在每個巢狀資料夾中產生 index & wiki」
//	「**每一層目錄指向子層次目錄**」
//
// 🔴 為什麼不是把 inventory.go 的上限調大：
//
//	`inventory.go` 的資料模型只有**兩層**——`inventoryCardPath(library)` 讓一個監看根
//	只能有一張卡，`invTopDirOf(rel)` 只取相對路徑第一段。實測（14 檔／4 層巢狀）產出
//	1 張卡、3 條「第一層 >> part_of >> 總覽頁」，第二層以下一個節點都沒有。
//	深度是**寫死在模型裡的**，不是參數。
//
// 🔴 為什麼是「一個資料夾一張卡」而不是「一張卡帶全部關聯」：
//
//	`rag_ingest_card` 對每條 rel 各發一個 POST（foreach）＋upsert 前置的逐筆 DELETE。
//	把 N 個目錄的關聯塞進同一張卡 ⇒ 單一請求 N 條 rel ⇒ 撞 Workers 免費層
//	**50 subrequests／請求**的天花板（`inventory.go` 的 `maxInventoryRels = 10`
//	就是為了這面牆才壓的）。拆成一卡一關聯後，**每個請求恆定 1 條 rel**，
//	天花板與資料夾數量脫鉤；變多的是請求「次數」，那個由既有的 `pace()` 節流吸收。
//
// 誠實界線（不要讓讀的人以為它做了沒做的事）：
//   - **空目錄不會有卡**。目錄集合是從 manifest 的檔案路徑反推的，而 `scan.go` 從來
//     沒有記錄過目錄本身（`d.IsDir()` 只用來決定要不要整棵跳過）。
//     「空資料夾也要看得見」是 `inkstone/arcrun-rag#106`，要先讓掃描記得目錄才做得到。
//   - 卡片內容是**機械統計**（檔數、子資料夾、最近改了什麼），不經 LLM ——
//     與 `inventory.go` 同一個理由：這種問題本機掃一遍就有答案，不該跟萃取排同一條隊。
package collector

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"path"
	"sort"
	"strings"
	"time"
)

const (
	// maxFolderCards＝單一監看根最多產幾張資料夾卡。
	//
	// 🔴 這是一道**會被講出來**的上限，不是安靜截斷（principles「no silent caps」）：
	// 超過時 `syncFolderCards` 會在結果訊息裡指名少送了幾個、從哪一層以下開始少。
	// 200 的由來：一次同步 200 個請求已經是既有 `MaxEventsPerRun`（預設同量級）的規模，
	// 再多就該讓使用者知道「你的資料夾深到要分批」，而不是我們自己吞掉。
	maxFolderCards = 200
	// maxFolderChildrenListed＝卡片上「子資料夾」段最多列幾個。純顯示上限，
	// 不影響關聯（關聯永遠由子卡自己宣告，見檔頭）。
	maxFolderChildrenListed = 50
	// maxFolderFilesListed＝卡片上「本層檔案」最多列幾個。
	maxFolderFilesListed = 100
)

// FolderCard＝一個資料夾在雲端的那一格。
type FolderCard struct {
	// Rel＝相對監看根的路徑（`""`＝根本身，根不由本檔產卡，見 BuildFolderCards）。
	Rel string
	// Page＝雲端頁名（也是三元組裡的節點名）。
	Page string
	// Path＝合成的 source_uri 鍵（不落地本機，同 inventoryCardPath 的慣例）。
	Path string
	// Parent＝父資料夾的頁名。這就是「每一層指向子層次目錄」的那條邊的另一端。
	Parent string
	// Content＝卡片全文（markdown）。
	Content string
}

// folderCardPageName 回傳某個相對路徑的卡片頁名。
// 根用 inventoryPageName（沿用既有那張總覽卡，不另開一張打對台）；
// 子層用 `資料夾：<根名>/<相對路徑>`——帶根名是因為不同監看根可能有同名子資料夾。
func folderCardPageName(absRoot, rel string) string {
	if rel == "" {
		return inventoryPageName(absRoot)
	}
	base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/"))
	if base == "" || base == "." || base == "/" {
		base = "知識資料夾"
	}
	return "資料夾：" + base + "/" + rel
}

// folderCardPath 回傳子資料夾卡的合成路徑。
// 帶 library（每個監看根唯一）＋相對路徑的 slug ⇒ 同帳號多資料夾不互撞。
// slug 為空（例如純中文路徑）時退成路徑雜湊，理由同 librarySlug 的 t89。
func folderCardPath(library, rel string) string {
	slug := pathSlug(rel)
	return ".arcrun-rag/wiki/cards/arcrun-資料夾-" + library + "-" + slug + ".md"
}

// pathSlug 把相對路徑轉成檔名安全的鍵。非 ASCII 一律轉底線分段（同 librarySlug 的規則），
// 全轉光就退成 sha256 前 8 hex——**路徑穩定所以鍵穩定，兩個不同路徑必然不同鍵**。
func pathSlug(rel string) string {
	var b strings.Builder
	lastUnderscore := false
	for _, r := range rel {
		switch {
		case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_':
			b.WriteRune(r)
			lastUnderscore = false
		case r >= 'A' && r <= 'Z':
			b.WriteRune(r + 32)
			lastUnderscore = false
		default:
			if !lastUnderscore && b.Len() > 0 {
				b.WriteRune('_')
				lastUnderscore = true
			}
		}
	}
	s := strings.Trim(b.String(), "_")
	sum := sha256.Sum256([]byte(rel))
	short := hex.EncodeToString(sum[:])[:8]
	if s == "" {
		return "d_" + short
	}
	// 一律接上雜湊尾碼：slug 會把「A/B」與「A_B」壓成同一個字串，
	// 只靠 slug 會讓兩個不同資料夾共用一張卡（靜默混層，同 t89 的形狀）。
	return s + "-" + short
}

// dirsFromEntries 從 manifest 的檔案路徑反推出**所有祖先目錄**（含中間層）。
// 回傳排序過的相對路徑（不含根的 ""）。
func dirsFromEntries(entries map[string]*ManifestEntry) []string {
	set := map[string]bool{}
	for p, e := range entries {
		if e == nil {
			continue
		}
		rel := strings.ReplaceAll(p, "\\", "/")
		seg := strings.Split(rel, "/")
		if len(seg) < 2 {
			continue // 根層檔案，沒有祖先目錄
		}
		for i := 1; i < len(seg); i++ {
			set[strings.Join(seg[:i], "/")] = true
		}
	}
	out := make([]string, 0, len(set))
	for d := range set {
		out = append(out, d)
	}
	sort.Strings(out)
	return out
}

// parentRel 回傳某個相對目錄的父目錄（頂層目錄的父是根，回 ""）。
func parentRel(rel string) string {
	if i := strings.LastIndexByte(rel, '/'); i > 0 {
		return rel[:i]
	}
	return ""
}

// BuildFolderCards 為每一個（含有檔案的）子資料夾各做一張卡。純函式、零 IO、輸出確定。
//
// 根本身不在回傳值裡——根那張是 `inventory.go` 的總覽卡，兩者分工不重疊：
// 總覽卡回答「這個監看根整體有什麼」，資料夾卡回答「這一層有什麼、它掛在誰底下」。
func BuildFolderCards(absRoot string, entries map[string]*ManifestEntry, library string) []FolderCard {
	dirs := dirsFromEntries(entries)

	// 每層直屬檔案 ／ 每層子目錄 ／ 每層含子孫的總檔數
	direct := map[string][]invFile{}
	subtotal := map[string]int{}
	children := map[string][]string{}
	for p, e := range entries {
		if e == nil {
			continue
		}
		rel := strings.ReplaceAll(p, "\\", "/")
		d := path.Dir(rel)
		if d == "." {
			d = ""
		}
		direct[d] = append(direct[d], invFile{path: path.Base(rel), mtime: e.Mtime})
		for cur := d; ; cur = parentRel(cur) {
			subtotal[cur]++
			if cur == "" {
				break
			}
		}
	}
	for _, d := range dirs {
		p := parentRel(d)
		children[p] = append(children[p], d)
	}
	for k := range children {
		sort.Strings(children[k])
	}

	cards := make([]FolderCard, 0, len(dirs))
	for _, rel := range dirs {
		page := folderCardPageName(absRoot, rel)
		var b strings.Builder
		sep := " " + strings.Repeat(">", 2) + " "

		b.WriteString("# " + page + "\n\n")

		b.WriteString("## 一句話定義\n")
		b.WriteString(fmt.Sprintf("這是「%s」底下的資料夾 `%s`：本層直接放了 %d 份文件，連同子資料夾共 %d 份。本頁由掃描自動整理，隨檔案變動即時更新，不經 AI 萃取。\n\n",
			path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/")),
			rel, len(direct[rel]), subtotal[rel]))

		// 子資料夾：這一段就是「每一層目錄指向子層次目錄」給人看的那一面
		// （給機器看的那一面是子卡自己的「## 關聯」）。
		b.WriteString("## 子資料夾\n")
		kids := children[rel]
		if len(kids) == 0 {
			b.WriteString("（沒有子資料夾）\n")
		}
		for i, c := range kids {
			if i >= maxFolderChildrenListed {
				b.WriteString(fmt.Sprintf("- （其餘 %d 個子資料夾未列出）\n", len(kids)-i))
				break
			}
			b.WriteString(fmt.Sprintf("- %s（%d 份）\n", path.Base(c), subtotal[c]))
		}
		b.WriteString("\n")

		b.WriteString("## 本層檔案\n")
		files := direct[rel]
		sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path })
		if len(files) == 0 {
			b.WriteString("（本層沒有直接放文件，內容都在子資料夾裡）\n")
		}
		for i, f := range files {
			if i >= maxFolderFilesListed {
				b.WriteString(fmt.Sprintf("- （其餘 %d 份未列出）\n", len(files)-i))
				break
			}
			b.WriteString("- " + f.path + "（" + invDateOf(f.mtime) + "）\n")
		}
		b.WriteString("\n")

		// ── 關聯：**恰好一條**，指向父層。天花板與資料夾數量脫鉤的關鍵就在這裡。──
		parent := folderCardPageName(absRoot, parentRel(rel))
		b.WriteString("## 關聯\n")
		b.WriteString("- " + page + sep + "part_of" + sep + parent + "\n")

		cards = append(cards, FolderCard{
			Rel: rel, Page: page, Path: folderCardPath(library, rel),
			Parent: parent, Content: b.String(),
		})
	}
	return cards
}

// syncFolderCards 在總覽卡之後送資料夾卡。回 nil＝這輪不必送。
//
// 冪等：每張卡的內容雜湊記在 `manifest.folder_card_hashes[rel]`，沒變不送。
// 失敗不擋主流程（同 syncInventory）；額度訊息換成人話。
// 上限：`maxFolderCards`，超過的**指名講出來**，不安靜吞掉。
func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dryRun bool, now time.Time) []DirectResult {
	if !hasEvents && len(m.FolderCardHashes) > 0 {
		return nil // 靜止輪不重送（同 syncInventory 的③）
	}
	lib := cfg.libraryFor(absRoot)
	cards := BuildFolderCards(absRoot, m.Entries, lib)
	if len(cards) == 0 {
		return nil
	}
	if m.FolderCardHashes == nil {
		m.FolderCardHashes = map[string]string{}
	}

	// 先刪掉「這輪已經不存在的資料夾」的記帳，避免雜湊表無限長大。
	live := map[string]bool{}
	for _, c := range cards {
		live[c.Rel] = true
	}
	for rel := range m.FolderCardHashes {
		if !live[rel] {
			delete(m.FolderCardHashes, rel)
		}
	}

	// 🔴 先挑「這輪真的要送的」，**再**套上限——順序反過來就會變成永遠只重試前 200 個，
	// 後面的資料夾一輩子送不上去（而且畫面上看起來一切正常）。
	type pending struct {
		card FolderCard
		hash string
	}
	var todo []pending
	for _, c := range cards {
		sum := sha256.Sum256([]byte(c.Content))
		h := "sha256:" + hex.EncodeToString(sum[:])
		if m.FolderCardHashes[c.Rel] == h {
			continue // 冪等：內容沒變
		}
		todo = append(todo, pending{card: c, hash: h})
	}
	if len(todo) == 0 {
		return nil
	}

	var dropped []string
	if len(todo) > maxFolderCards {
		for _, t := range todo[maxFolderCards:] {
			dropped = append(dropped, t.card.Rel)
		}
		todo = todo[:maxFolderCards]
	}

	var out []DirectResult
	wf := cfg.CardIngestWF
	if wf == "" {
		wf = "rag_ingest_card"
	}
	mach := cfg.machineIdentity()
	for _, t := range todo {
		c := t.card
		res := DirectResult{Type: "folder", Path: c.Path}
		if dryRun {
			res.Status = "planned"
			out = append(out, res)
			continue
		}
		pace()
		status, _, err := cfg.postJSON(stepFolderCard, cfg.triggerURL(wf), map[string]any{
			"page_name":     c.Page,
			"path":          c.Path,
			"card_content":  c.Content,
			"library":       lib,
			"machine":       mach.ID,
			"machine_label": mach.Label,
		})
		res.HTTPStatus = status
		if err != nil {
			res.Status = "failed"
			if isQuotaExhausted(err.Error()) {
				res.Error = "雲端今天的額度用完了，資料夾索引會稍後自動補送（不影響檔案同步）"
			} else {
				res.Error = "資料夾索引上傳失敗（不影響檔案同步）：" + err.Error()
			}
			out = append(out, res)
			// 這一張失敗就停手：後面幾百張多半會撞同一面牆，繼續撞只是把
			// 「一個壞掉的雲端」變成「幾百筆一樣的錯誤」（t195 的 1387 輪教訓）。
			break
		}
		res.Status = "ingested"
		m.FolderCardHashes[c.Rel] = t.hash
		out = append(out, res)
	}

	if len(dropped) > 0 {
		out = append(out, DirectResult{
			Type:   "folder",
			Status: "skipped",
			Error: fmt.Sprintf("資料夾超過單輪上限 %d 個，這輪少送 %d 個（例：%s）——下一輪會接著送",
				maxFolderCards, len(dropped), strings.Join(firstN(dropped, 3), "、")),
		})
	}
	return out
}

// firstN 取前 n 個（不足就全給）。只給訊息用。
func firstN(ss []string, n int) []string {
	if len(ss) <= n {
		return ss
	}
	return ss[:n]
}
