// manifest.go — collector 本機 manifest 讀寫（SDD ingest-hash-trigger design §2）。
// manifest 只由 collector 寫入，雲端不得回寫（design §6-4）。
package collector

import (
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// ManifestEntry 是單一檔案在 manifest 裡的狀態。
//   - mtime 只決定「要不要重算 hash」（fast-path），絕不作為內容變更判準。
//   - ingested_hash 與 content_hash 分開存＝可表達「已變更但尚未成功 ingest」；
//     上傳失敗不更新 ingested_hash，天然可重試。本階段（不接網路）永不寫 ingested_hash，
//     留給之後的上傳/webhook task 在成功後回寫。
type ManifestEntry struct {
	ContentHash  string `json:"content_hash"`
	Size         int64  `json:"size"`
	Mtime        int64  `json:"mtime"`
	IngestedHash string `json:"ingested_hash,omitempty"`
	IngestedAt   int64  `json:"ingested_at,omitempty"`
	// ExtractedBy＝這張卡是誰萃的（"claude"／"gemma"／""＝無萃取器的直送路）。
	//
	// 為什麼要記（leo 2026-07-27 問「如果已經萃過了它知道嗎？」時發現的缺口）：
	// 原本 manifest 只記「萃過了」不記「誰萃的」。防重複本來就成立（hash 相同就跳過），
	// 但**換萃取器時無從分辨哪些卡是舊萃取器產的**——claude 萃的卡和 gemma 萃的卡
	// 品質不同卻混在同一個知識庫裡，想重萃也不知道該重萃哪些。
	//
	// 本欄位**不改變任何現有行為**（純記錄），但它是「換萃取器要不要重萃」這個決定的前提：
	// 沒有它，之後想分辨就永遠分辨不了（舊資料補不回來）。
	ExtractedBy string `json:"extracted_by,omitempty"`

	// ── 失敗退避（t195，2026-08-05）────────────────────────────────────────────
	// 病（leo 實撞）：`小果被AFTEE詐貸.pdf` 因雲端 401 失敗後，**manifest 完全不記失敗**
	// ⇒ 下一輪掃描又把它當「新檔」⇒ 無退避、無上限地重試。
	// 實測：**1387 輪、跨 11 小時**，每輪 3.2~3.8 秒全花在撞同一面牆上；
	// 而且它排在佇列前面 ⇒ **一個壞檔就把整個資料夾的同步拖住**。
	// leo：「不只推上去慢，原先萃檔案速度也快，現在也花了十幾分才萃完」——
	// **萃取根本沒變慢，慢的是重試**（log 的 `FOREACH 所有 5 項目均失敗` 證明卡早就萃好了）。
	//
	// 解：記下失敗次數與下次可重試時間，指數退避＋次數上限。
	// 這是**獨立於當次錯誤**的架構缺陷——401 修好了，下次換別的錯照樣卡死，所以要修這裡。
	FailCount  int   `json:"fail_count,omitempty"`   // 連續失敗次數（成功即歸零）
	LastFailAt int64 `json:"last_fail_at,omitempty"` // 最後一次失敗的 unix 秒
	NextRetry  int64 `json:"next_retry,omitempty"`   // 早於這個時間不重試（0＝可立即重試）
	// LastError＝最後一次失敗的**真正原因**（原文，不改寫）。
	//
	// 🔴 leo 2026-08-06 立的原則：「**別人的錯誤一律要顯示給用戶看，
	//    不然就會變成我的錯誤，導致客服**」。
	//    先前退避一開始，畫面就只剩「上次失敗（第 4 次），58m 後重試」，
	//    真因（Cloudflare「當日免費額度用完」／「這份 PDF 沒有文字層」）**當場消失**
	//    ⇒ 使用者以為是我們壞掉。原因必須跟著 entry 存活到下次成功為止。
	LastError string `json:"last_error,omitempty"`

	// ── 雲端對帳（`inkstone/arcrun-rag#140`，2026-08-26）──────────────────────
	// 病：上面那個 IngestedHash 的章**永遠不會過期**。雲端 08-14 被重裝／清空之後，
	// 檔案內容沒變 ⇒ 不產生事件 ⇒ 永遠不會重送，而且沒有任何地方會說話。
	// ⇒ 章的意思要從「我送過了」改成「我送過了**而且雲端現在還有**」，
	//   後半句只能去問雲端（見 cloud_audit.go）。這三個欄位是那個問答的記帳。
	//
	// CloudCheckedAt＝最後一次向雲端確認過這個檔的時間（unix 秒）。
	// 用途有二：①決定「太久沒對帳」該重問（cloudAuditRecheckInterval）；
	// ②讓候選排序把最久沒對的排前面 ⇒ 巨量資料夾也會被輪完，不會有人永遠排不到。
	CloudCheckedAt int64 `json:"cloud_checked_at,omitempty"`
	// CloudMissingAt＝最後一次「雲端查不到、章被拔掉」的時間（unix 秒，0＝從沒發生）。
	// 🔴 它同時是**防重送迴圈的閘**：雲端 ingest 非同步，剛送出去那幾秒查不到是正常的，
	//    沒有這個窗口，對帳下一輪又判它不在 ⇒ 每輪重送 ⇒ 把使用者的 AI 額度燒光
	//    ⇒ 那就是「把一個 bug 換成另一個」（#140 驗收條件 5 明文禁止）。
	CloudMissingAt int64 `json:"cloud_missing_at,omitempty"`
	// NoCloudCard＝這一次 ingest 蓋章時**沒有任何卡片被送上雲**
	//（萃取判定「無可萃取概念」，cards 為空 ⇒ direct.go 的送卡迴圈一圈都沒跑）。
	// 🔴 不記這一格的話，對帳每天都會查到「雲端沒有它」⇒ 每天重萃一次、永遠停不下來。
	//    舊的 manifest 沒有這一格＝當成「送過」，那正是本票要救的那批檔的處境。
	NoCloudCard bool `json:"no_cloud_card,omitempty"`
}

// retryBackoff 退避階梯：1m → 5m → 15m → 1h → 6h，之後每次 6h。
var retryBackoff = []time.Duration{
	1 * time.Minute, 5 * time.Minute, 15 * time.Minute, 1 * time.Hour, 6 * time.Hour,
}

// MaxFailBeforeSkip 連續失敗達此次數 → 該檔暫停自動重試，不再拖住佇列。
// 使用者改檔（content hash 變）或按「立刻同步」時仍會重試（見 MarkFailed／ShouldRetry）。
const MaxFailBeforeSkip = 8

// Manifest 對應一個被勾選的資料夾。
type Manifest struct {
	FolderID string                    `json:"folder_id"`
	// FolderCardHashes＝每個子資料夾索引卡的內容雜湊（鍵＝相對監看根的目錄路徑）。
	// 冪等用：內容沒變就不重送（同 InventoryHash 的角色，只是一層變多層）。
	// 資料夾消失時由 syncFolderCards 清掉對應的鍵，不讓這張表無限長大。
	// （`inkstone/Arcrun#146` 碎形目錄索引）
	FolderCardHashes map[string]string `json:"folder_card_hashes,omitempty"`
	Root     string                    `json:"root"`
	Entries  map[string]*ManifestEntry `json:"entries"`
	// InventoryHash＝最後一次**成功送達雲端**的資料夾總覽卡內容雜湊（結構先行，
	// InkStoneCo#43，見 inventory.go）。與 entries 的跨輪 carry 陷阱無關——這是
	// Manifest 層欄位，Scan() 的 rebuild 只重建 Entries，不會碰它，天然跨輪存活。
	InventoryHash string `json:"inventory_hash,omitempty"`
	// InventoryFailHash／InventoryNextRetry＝總覽卡上一次送失敗的內容雜湊與下次可重試
	// 時間（unix）。存在理由＝t195 同款：積壓卡住時每輪都有事件，沒有這道退避，
	// 雲端一壞就是每 5 秒撞一次。內容變了（雜湊不同）視同新卡，立即可再試。
	InventoryFailHash  string `json:"inventory_fail_hash,omitempty"`
	InventoryNextRetry int64  `json:"inventory_next_retry,omitempty"`
	// SourceOriginRepairedAt＝這個監看根的「### 出處」已全部改成
	// 「機器 › 知識庫 › 庫內路徑」的時間（unix 秒，0＝還沒修過）。
	// 🔴 `inkstone/Arcrun#167`：舊卡寫的是 `../<檔名>`（daemon 的內部目錄結構），
	//    AI 照著答，使用者走不到那個檔。修產生端只治得了新卡，既有的要就地重推
	//    （見 sourcerepair.go）。這一格的存在是為了**修完就不再每輪掃全庫**——
	//    沒有它，這個修復會變成一個永遠在跑的背景工作。
	SourceOriginRepairedAt int64 `json:"source_origin_repaired_at,omitempty"`

	// PendingTakedowns＝改名／搬移後「舊路徑」在雲端知識庫裡還沒下架成功的待辦清單
	// （key=舊相對路徑，value=該路徑當時導出的頁名）。InkStoneCo#44 ⑩：
	//
	// direct 模式把 renamed 事件當 added 處理（用新路徑重送一次萃取），但從未告訴
	// 雲端「舊頁名／舊路徑那份已經死了」——純改檔名時舊頁名的舊卡永久留著；搬到
	// 別的資料夾時（basename 不變 ⇒ 新舊頁名相同）舊的不刪、新的照寫，kbdb 裡同一份
	// 文件變兩套，其中一套指向已不存在的路徑，而且沒有任何機制會回頭發現它。
	//
	// 為什麼要持久化而不是「失敗了下一輪自然重試」（removed 事件的作法）：removed
	// 事件靠「檔案仍然不在」讓 Scan() 每輪重新偵測、重新補發；但 renamed 的配對
	// （removed×added 以 content_hash 配對）只在偵測到的那一輪出現一次，舊路徑已經
	// 不在任何一邊的掃描結果裡，下一輪不會再有 renamed 事件把它帶出來。不記住它，
	// 一次下架失敗（雲端剛好那幾秒掛掉）就永久遺失，舊卡從此不會再被清。
	PendingTakedowns map[string]string `json:"pending_takedowns,omitempty"`

	// CloudAuditAt＝這個資料夾最後一次跑雲端對帳批次的時間（unix 秒）。
	// 純節流用（cloudAuditFolderInterval）：daemon 預設 5 秒一輪，沒有這道閘
	// 就是每 5 秒對雲端發 20 個請求。與 InventoryHash 同為 Manifest 層欄位，
	// Scan() 的 rebuild 只重建 Entries，天然跨輪存活。
	CloudAuditAt int64 `json:"cloud_audit_at,omitempty"`
	// ── InkStoneCo#44 線 A：資料夾樹的冪等記帳（見 foldertree.go）───────────────
	// FolderTreeHash＝最後一次**成功送達雲端**的整棵樹內容雜湊；
	// FolderTreeFailHash／FolderTreeNextRetry＝上一次送失敗的內容與退避到期時間。
	// 存在理由與上面 InventoryHash 那三個完全同款（t195：沒有退避，雲端一壞就是每 5 秒撞一次）。
	// 同屬 Manifest 層欄位 ⇒ Scan() 的 rebuild 只重建 Entries，天然跨輪存活。
	// FolderTreeNextSend＝**成功送出後**的最小間隔到期時間（folderTreeMinInterval）。
	// 與上面兩個 *Retry 欄位是不同的東西：那兩個是「失敗退避」（t195 的慣例），
	// 這個是「成功也要節流」。需要它的原因是這棵樹沒有「本輪無事件就不送」那道閘
	//（空資料夾一個事件都沒有）⇒ 初次同步時內容雜湊每輪都變，沒有它就是每 5 秒
	// 一次雲端 KV 寫入，一小時燒光當天額度。
	FolderTreeHash      string `json:"folder_tree_hash,omitempty"`
	FolderTreeFailHash  string `json:"folder_tree_fail_hash,omitempty"`
	FolderTreeNextRetry int64  `json:"folder_tree_next_retry,omitempty"`
	FolderTreeNextSend  int64  `json:"folder_tree_next_send,omitempty"`
}

// QueueTakedown 記一筆「這個舊路徑（連同當時的頁名）還沒在雲端下架」的待辦。
// 冪等：同一路徑重複呼叫只覆蓋頁名（理論上不會變，但不假設呼叫端不會重複觸發）。
func (m *Manifest) QueueTakedown(oldPath, pageName string) {
	if m.PendingTakedowns == nil {
		m.PendingTakedowns = map[string]string{}
	}
	m.PendingTakedowns[oldPath] = pageName
}

// ClearTakedown 下架成功後從待辦清單移除。
func (m *Manifest) ClearTakedown(oldPath string) {
	delete(m.PendingTakedowns, oldPath)
}

// newUUID 產生 RFC 4122 v4 UUID（純 stdlib）。
func newUUID() (string, error) {
	var b [16]byte
	if _, err := rand.Read(b[:]); err != nil {
		return "", err
	}
	b[6] = (b[6] & 0x0f) | 0x40 // version 4
	b[8] = (b[8] & 0x3f) | 0x80 // variant 10
	return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}

// LoadManifest 讀 manifest 檔；不存在＝新資料夾，生成 folder_id。
func LoadManifest(path, root string) (*Manifest, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			id, uerr := newUUID()
			if uerr != nil {
				return nil, uerr
			}
			return &Manifest{FolderID: id, Root: root, Entries: map[string]*ManifestEntry{}}, nil
		}
		return nil, err
	}
	var m Manifest
	if err := json.Unmarshal(data, &m); err != nil {
		return nil, fmt.Errorf("manifest %s 解析失敗（不覆寫、直接報錯）: %w", path, err)
	}
	if m.Entries == nil {
		m.Entries = map[string]*ManifestEntry{}
	}
	if m.FolderID == "" {
		id, uerr := newUUID()
		if uerr != nil {
			return nil, uerr
		}
		m.FolderID = id
	}
	if root != "" {
		m.Root = root
	}
	return &m, nil
}

// MarkIngested 是「整條 ingest 鏈成功」後的回寫鉤子（design §2）：把該路徑的
// ingested_hash 接上當時送出的 source_hash。**R2 上傳成功不呼叫它**——上傳只是鏈的
// 第一環，要等 task 4（named-webhook → ingest workflow）確認成功才回寫；在那之前
// 同檔每輪重發 added/modified＝設計內重試，R2 端靠 HEAD no-op 天然冪等、零浪費。
// 回傳 false＝路徑已不在 manifest（例如回報前檔案又被改名/刪除），呼叫端自行決定忽略或告警。
func (m *Manifest) MarkIngested(path, sourceHash string, at int64) bool {
	return m.MarkIngestedBy(path, sourceHash, at, "")
}

// MarkIngestedBy 同 MarkIngested，另記「這輪是誰萃的」（extractor＝"claude"／"gemma"／""）。
//
// 為什麼另開一支而不是改 MarkIngested 的簽名：MarkIngested 有多個呼叫點
// （direct 兩處＋trigger.go 的 MarkIngestedEvents＋測試），改簽名會擴散破壞。
// 走無萃取器路徑（sync／直送）的呼叫端不必知道這個欄位，維持原簽名最小侵入。
func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor string) bool {
	e, ok := m.Entries[path]
	if !ok {
		return false
	}
	e.IngestedHash = sourceHash
	e.IngestedAt = at
	e.ExtractedBy = extractor
	// 成功即清掉失敗狀態（t195）：下次再壞會從第一階退避重新算起。
	e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0
	e.LastError = ""
	// #140：預設「這次有送卡上雲」；真的一張卡都沒送的那條路由呼叫端補打
	// MarkNoCloudCard（見 direct.go 的 cards 為空分支）。預設值放這裡而不是
	// 讓呼叫端每次都設，是因為漏設的方向要落在**安全的那一邊**：
	// 誤標 false ⇒ 頂多多對一次帳（一個唯讀請求）；誤標 true ⇒ 這個檔從此
	// 不再被對帳，雲端掉了也沒人發現——那正是本票在修的病。
	e.NoCloudCard = false
	// 🔴 CloudCheckedAt／CloudMissingAt **刻意不清**：
	//    前者是輪值排序的依據，後者是防重送迴圈的 grace 窗口與「補送過」的證據
	//    （ResyncSummary 靠 IngestedAt >= CloudMissingAt 判斷這份是不是剛補回來的）。
	//    清掉等於把剛補送成功的那批檔立刻放回可再拔章的池子。
	return true
}

// MarkNoCloudCard 標記「這次 ingest 沒有任何卡片被送上雲端」。
// 只有萃取判定「無可萃取概念」（cards 為空）的那條路會呼叫。見 NoCloudCard 欄位註解。
func (m *Manifest) MarkNoCloudCard(path string) bool {
	e, ok := m.Entries[path]
	if !ok {
		return false
	}
	e.NoCloudCard = true
	return true
}

// MarkFailed 記錄一次失敗並排定下次可重試時間（t195 指數退避）。
//
// 為什麼要記在 manifest 而不是記憶體：collector 每輪是獨立 process
// （`direct --once` 由看守器反覆拉起），記憶體狀態一輪就沒了——
// 這正是原本「1387 輪重試同一個檔」的原因：每輪都以為自己是第一次。
func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
	e, ok := m.Entries[path]
	if !ok {
		return false
	}
	e.FailCount++
	e.LastFailAt = at
	if strings.TrimSpace(reason) != "" {
		e.LastError = reason // 存真因；退避訊息由呼叫端另外組，不覆蓋這裡
	}
	idx := e.FailCount - 1
	if idx >= len(retryBackoff) {
		idx = len(retryBackoff) - 1
	}
	e.NextRetry = at + int64(retryBackoff[idx].Seconds())
	return true
}

// ShouldRetry 回報「這個檔現在該不該送」。
//
//   - 從沒失敗過 → true（行為與 t195 前一字不變）
//   - 還在退避窗口內 → false（**這是止血點：壞檔不再每 4 秒撞一次牆拖住整個佇列**）
//   - 連續失敗 ≥ MaxFailBeforeSkip → false（暫停自動重試）
//
// force＝使用者按「立刻同步」：忽略退避與上限一律重送
// （人明確要求時不該被機器的退避擋住）。
// 另：使用者改檔會讓 content hash 變 → 走的是「內容變更」路徑，本函式不介入。
func (m *Manifest) ShouldRetry(path string, now int64, force bool) bool {
	e, ok := m.Entries[path]
	if !ok || e.FailCount == 0 {
		return true
	}
	if force {
		return true
	}
	if e.FailCount >= MaxFailBeforeSkip {
		return false
	}
	return now >= e.NextRetry
}

// Save 原子寫入（temp + rename），避免掃描中斷留半個 JSON。
func (m *Manifest) Save(path string) error {
	data, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	tmp, err := os.CreateTemp(dir, ".manifest-*.tmp")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	if _, err := tmp.Write(append(data, '\n')); err != nil {
		tmp.Close()
		os.Remove(tmpName)
		return err
	}
	if err := tmp.Close(); err != nil {
		os.Remove(tmpName)
		return err
	}
	return os.Rename(tmpName, path)
}
