// inventory.go — 結構先行：掃描一結束就把「這個資料夾有哪些檔案／最近改了什麼」
// 做成**機械總覽卡**送上知識庫（InkStoneCo#43，2026-08-15）。
//
// 為什麼要有這個（leo 2026-08-15 量測）：266 個可萃檔真正花在算的只有 ~20 分鐘、
// 成本 < $0.5，但使用者實際等了 ~4 天——多數時間在等 Workers AI 每日額度隔天恢復。
// 而「這裡面有哪些檔案／最近改了什麼」這種**本地掃一遍就有答案、免費、秒級**的問題
// （實測 1,335 檔含逐檔 SHA256 只要 0.254 秒），原本跟「這份文件在講什麼」排同一條隊、
// 撞同一面額度牆——因為 part_of 目錄邊只在雲端 parse_card（LLM 萃取之後）才會長出來。
//
// 設計取捨（為什麼走 rag_ingest_card、不開新 workflow）：
//   - 每個用戶實例**本來就裝著** rag_ingest_card（compile-workflows.mjs 的四條之一），
//     走它＝今天所有既有實例直接受益，不需要任何雲端部署／升級。
//   - 它自帶同頁名 upsert（pick_stale 先刪舊再寫）＝重送不堆副本，冪等免費拿到。
//   - 它零 LLM（機械切塊＋寫 KBDB）＝不吃 neurons、額度冷卻期間照常可用。
//   - 卡片的「## 關聯」段天生會被 parse 成 triplets ⇒ 目錄結構直接長進圖譜，
//     rag-chat 的 fetch_triplets／kw_search 都查得到，檢索端零改動。
//
// 紅線對齊：本檔**不寫任何本機檔案**（總覽卡只存在於雲端），不動使用者原稿；
// 資料一律走 webhook API（KBDB API-as-Wall，D38）。
package collector

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

const (
	// maxInventoryListed：「## 檔案清單」逐檔點名的上限。超過就改列各目錄份數——
	// 與 scan.go maxOtherNames 同一個判準：少量點名有資訊量，巨量點名是噪音；
	// 也守住 rag_ingest_card 端的量（block 過大／foreach 過多都會踩雲端上限，
	// 見 wiki mistakes「http_request 零件抓大回應直接爆」）。
	maxInventoryListed = 200
	// maxInventoryRecent：「## 最近改了什麼」條數上限。
	maxInventoryRecent = 20
	// maxInventoryRels：「## 關聯」（目錄 part_of）條數上限。刻意壓低：
	// rag_ingest_card 對每條 rel 各發一個 POST（foreach），加上 upsert 前置的
	// 逐筆 DELETE，rel 太多會撞 Workers 免費層 50 subrequests/請求的天花板。
	maxInventoryRels = 10
	// maxInventoryDirLines：巨量模式下「各目錄份數」最多列幾行。
	maxInventoryDirLines = 30
)

// inventoryPageName 回傳總覽卡的頁名。含資料夾 basename＝人看得懂「這是哪個資料夾」；
// 不同監看根的 basename 相同時靠 library（每根唯一）分頁，見 inventoryCardPath。
func inventoryPageName(absRoot string) string {
	base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/"))
	if base == "" || base == "." || base == "/" {
		base = "知識資料夾"
	}
	return "資料夾總覽：" + base
}

// inventoryCardPath 回傳總覽卡的合成路徑（雲端 source_uri 用的鍵，**不落地本機**）。
// 掛在 .arcrun-rag/（機器工作區目錄，machinemark.go 的既有標記慣例）底下＝一眼可辨
// 「這是 Arcrun 產的、不是使用者的檔」；帶 library（每個監看根唯一，librarySlug 有
// 雜湊後備）＝同帳號多資料夾時 source_uri 不互撞。
func inventoryCardPath(library string) string {
	return ".arcrun-rag/wiki/cards/arcrun-資料夾總覽-" + library + ".md"
}

// invFile 是總覽卡的最小輸入（從 manifest entries 抽出，便於排序與測試）。
type invFile struct {
	path  string
	mtime int64
}

// invDateOf 把 mtime 格式化成使用者看的日期（本機時區；使用者要的是「哪天改的」量級）。
func invDateOf(mtime int64) string {
	if mtime <= 0 {
		return "（日期不明）"
	}
	return time.Unix(mtime, 0).Local().Format("2006-01-02")
}

// invTopDirOf 回傳相對路徑的第一層目錄；根層檔案回「（根目錄）」。
func invTopDirOf(rel string) string {
	rel = strings.ReplaceAll(rel, "\\", "/")
	if i := strings.IndexByte(rel, '/'); i > 0 {
		return rel[:i]
	}
	return "（根目錄）"
}

// BuildInventoryCard 把 manifest entries（Scan 後＝檔案系統現況）做成一張機械總覽卡。
// 純函式、零 IO、輸出確定（同一份輸入永遠同一份輸出）——冪等判斷（內容雜湊）與
// 測試都靠這一點。回傳（頁名, 卡片 markdown）。
func BuildInventoryCard(absRoot string, entries map[string]*ManifestEntry, library string) (string, string) {
	page := inventoryPageName(absRoot)

	files := make([]invFile, 0, len(entries))
	for p, e := range entries {
		if e == nil {
			continue
		}
		files = append(files, invFile{path: strings.ReplaceAll(p, "\\", "/"), mtime: e.Mtime})
	}
	sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path })

	var b strings.Builder
	sep := " " + strings.Repeat(">", 2) + " " // 三元組分隔符（組字串產生，同 lint_test 慣例）

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

	// ── 一句話定義：總數＋格式分佈 ──
	extCount := map[string]int{}
	for _, f := range files {
		ext := strings.ToLower(path.Ext(f.path))
		if ext == "" {
			ext = "（無副檔名）"
		}
		extCount[ext]++
	}
	exts := make([]string, 0, len(extCount))
	for e := range extCount {
		exts = append(exts, e)
	}
	sort.Slice(exts, func(i, j int) bool {
		if extCount[exts[i]] != extCount[exts[j]] {
			return extCount[exts[i]] > extCount[exts[j]]
		}
		return exts[i] < exts[j]
	})
	extParts := []string{}
	for i, e := range exts {
		if i >= 5 {
			rest := 0
			for _, e2 := range exts[i:] {
				rest += extCount[e2]
			}
			extParts = append(extParts, fmt.Sprintf("其他 %d 份", rest))
			break
		}
		extParts = append(extParts, fmt.Sprintf("%s %d 份", strings.TrimPrefix(e, "."), extCount[e]))
	}
	b.WriteString("## 一句話定義\n")
	if len(files) == 0 {
		b.WriteString("這個資料夾目前沒有可收錄的文件（本頁由掃描自動整理，隨檔案變動即時更新，不經 AI 萃取）。\n\n")
	} else {
		b.WriteString(fmt.Sprintf("這個資料夾目前有 %d 份文件（%s），本頁由掃描自動整理，隨檔案變動即時更新，不經 AI 萃取。\n\n",
			len(files), strings.Join(extParts, "、")))
	}

	// ── 最近改了什麼：mtime 新到舊，路徑字母序破平 ──
	recent := make([]invFile, len(files))
	copy(recent, files)
	sort.Slice(recent, func(i, j int) bool {
		if recent[i].mtime != recent[j].mtime {
			return recent[i].mtime > recent[j].mtime
		}
		return recent[i].path < recent[j].path
	})
	if n := len(recent); n > maxInventoryRecent {
		recent = recent[:maxInventoryRecent]
	}
	b.WriteString("## 最近改了什麼\n")
	if len(recent) == 0 {
		b.WriteString("（目前沒有檔案）\n")
	}
	for _, f := range recent {
		b.WriteString("- " + invDateOf(f.mtime) + "　" + f.path + "\n")
	}
	b.WriteString("\n")

	// ── 檔案清單：少量逐檔點名（含日期），巨量改列各目錄份數＋每目錄最新一檔 ──
	dirCount := map[string]int{}
	dirNewest := map[string]invFile{}
	for _, f := range files {
		d := invTopDirOf(f.path)
		dirCount[d]++
		if cur, ok := dirNewest[d]; !ok || f.mtime > cur.mtime || (f.mtime == cur.mtime && f.path < cur.path) {
			dirNewest[d] = f
		}
	}
	dirs := make([]string, 0, len(dirCount))
	for d := range dirCount {
		dirs = append(dirs, d)
	}
	sort.Slice(dirs, func(i, j int) bool {
		if dirCount[dirs[i]] != dirCount[dirs[j]] {
			return dirCount[dirs[i]] > dirCount[dirs[j]]
		}
		return dirs[i] < dirs[j]
	})

	b.WriteString("## 檔案清單\n")
	switch {
	case len(files) == 0:
		b.WriteString("（目前沒有檔案）\n")
	case len(files) <= maxInventoryListed:
		for _, f := range files {
			b.WriteString("- " + f.path + "（" + invDateOf(f.mtime) + "）\n")
		}
	default:
		b.WriteString(fmt.Sprintf("共 %d 份，檔案較多，以下列各目錄的份數與最新一檔：\n", len(files)))
		for i, d := range dirs {
			if i >= maxInventoryDirLines {
				rest := 0
				for _, d2 := range dirs[i:] {
					rest += dirCount[d2]
				}
				b.WriteString(fmt.Sprintf("- （其餘 %d 個目錄共 %d 份）\n", len(dirs)-i, rest))
				break
			}
			nf := dirNewest[d]
			b.WriteString(fmt.Sprintf("- %s：%d 份（最新：%s，%s）\n", d, dirCount[d], nf.path, invDateOf(nf.mtime)))
		}
	}
	b.WriteString("\n")

	// ── 關聯：目錄 part_of 總覽頁（parse_card 會再自動補「總覽頁 part_of knowledge-base」）──
	b.WriteString("## 關聯\n")
	relDirs := dirs
	if len(relDirs) > maxInventoryRels {
		relDirs = relDirs[:maxInventoryRels]
	}
	for _, d := range relDirs {
		if d == "（根目錄）" {
			continue
		}
		b.WriteString("- " + d + sep + "part_of" + sep + page + "\n")
	}

	return page, b.String()
}

// inventoryRetryDelay：總覽卡送失敗後，同一份內容多久以後才准再試。
// 積壓卡住時每輪都有事件（hasEvents 恆真），沒有這道退避，雲端一壞就是
// 每 5 秒撞一次（t195 的 1387 輪教訓，同款病）。內容變了（雜湊不同）不受此限。
const inventoryRetryDelay = 10 * time.Minute

// syncInventory 在每輪掃描後、逐檔萃取**之前**送總覽卡。回 nil＝這輪不必送。
//
// 冪等／防撞四層：①卡片內容 sha256 記在 manifest.inventory_hash，內容沒變不送；
// ②同一份內容剛送失敗過（inventory_fail_hash）→ 10 分鐘內不重試；
// ③已有成功版本且本輪無事件 → 不送（總覽跟著變動走，不跟著輪詢走）；
// ④雲端 rag_ingest_card 自帶同頁名 upsert，就算重送也不堆副本。
//
// 失敗不擋同步主流程（不設 exit）：總覽是加值層，它壞了檔案同步照常走；
// 但誠實回報 failed result，不假綠。額度用完的失敗換成人話（不裸露 4006/502，
// 同 2026-08-07 task 2 的判準），其餘失敗保留原文（遠端查得到真因，08-06 教訓）。
func syncInventory(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dryRun bool, now time.Time) *DirectResult {
	lib := cfg.libraryFor(absRoot)
	page, card := BuildInventoryCard(absRoot, m.Entries, lib)
	if len(m.Entries) == 0 && m.InventoryHash == "" {
		return nil // 空資料夾且從沒公告過＝沒東西好說
	}
	sum := sha256.Sum256([]byte(card))
	h := "sha256:" + hex.EncodeToString(sum[:])
	if h == m.InventoryHash {
		return nil // 冪等①：內容沒變（也涵蓋大量刪除防呆輪：entries 保留＝雜湊不變）
	}
	if h == m.InventoryFailHash && now.Unix() < m.InventoryNextRetry {
		return nil // 防撞②：同一份內容剛失敗過，退避窗口內不重撞
	}
	if !hasEvents && m.InventoryHash != "" {
		return nil // ③：已有成功版本的靜止輪不重送
	}
	res := &DirectResult{Type: "inventory", Path: inventoryCardPath(lib)}
	if dryRun {
		res.Status = "planned"
		return res
	}
	pace() // 觸發雲端前一律節流（2026-08-07 pacing 慣例）
	wf := cfg.CardIngestWF
	if wf == "" {
		wf = "rag_ingest_card"
	}
	// machine（`inkstone/mira#6`）：總覽卡的 path 是**合成**的（inventoryCardPath），
	// 只帶 library ⇒ 兩台機器上同名的資料夾會生出一模一樣的鍵，後同步的那台會把
	// 前一台的總覽卡蓋掉。這裡與逐檔卡走同一組欄位，不另開一種。
	mach := cfg.machineIdentity()
	status, _, err := cfg.postJSON(stepInventory, cfg.triggerURL(wf), map[string]any{
		"page_name":     page,
		"path":          res.Path,
		"card_content":  card,
		"library":       lib,
		"machine":       mach.ID,
		"machine_label": mach.Label,
	})
	res.HTTPStatus = status
	if err != nil {
		res.Status = "failed"
		if isQuotaExhausted(err.Error()) {
			// 額度訊息不裸露錯誤碼（同 task 2 判準）；額度的完整三句話由萃取路的
			// quotaState 負責，這裡只講「這件事」的狀態與下一步。
			res.Error = "雲端今天的額度用完了，資料夾總覽會稍後自動補送（不影響檔案同步）"
		} else {
			res.Error = "資料夾總覽上傳失敗（不影響檔案同步）：" + err.Error()
		}
		m.InventoryFailHash = h
		m.InventoryNextRetry = now.Add(inventoryRetryDelay).Unix()
		return res
	}
	res.Status = "ingested"
	m.InventoryHash = h
	m.InventoryFailHash = ""
	m.InventoryNextRetry = 0
	return res
}
