// vault_footprint_test.go — arcrun-rag#60 第二輪，驗收條件④「回頭看：上一批為什麼沒抓到」。
//
// ══════════════════════════════════════════════════════════════════════════
// 上一批（1e36bb1）為什麼漏掉這次這個？
// ══════════════════════════════════════════════════════════════════════════
//
// 上一批問的問題是：「**卡片落在哪個目錄？**」
// 於是它加了 cardsRelDirFor()，並且只寫了一支點測（TestCardsRelDirFor）去驗
// 「這個函式回傳的常數對不對」。它從頭到尾**沒有問過「檔名叫什麼」**——
// 而 Logseq/Obsidian 的頁名正是從 basename 來的，撞名撞的是名字不是目錄。
//
// 更根本的形狀問題：那批測試是**點測**（挑一個已知的寫檔點，驗它的回傳值）。
// 點測只能保護「測試作者當時想得到的那幾個寫檔點」，於是這些全部漏在網外：
//
//	· snapshotCards() 仍硬寫 cardsRelDir（vault 上 snapshot 到空目錄）
//	· ExtractWithClaude 的檔名由 prompt 決定，Go 測試碰不到
//	· safeWriteCard 的 .bak-<ts> 備份檔沒人問過它叫什麼名字
//	· 之後任何人新增第 N 個寫檔點——沒有任何機制會提醒他要帶標記
//
// ⇒ 這正是「規則寫了、卻沒有機制驗證有沒有照做」的同款（頂層 CLAUDE.md 記過
//
//	history-first／KBDB-first／stage-first 三個同形狀的事故）。
//
// ══════════════════════════════════════════════════════════════════════════
// 所以這一批補的是「足跡測試」，不是再多一支點測
// ══════════════════════════════════════════════════════════════════════════
//
// 判準從「某個函式回傳什麼」換成「**跑完一輪之後，這個資料夾多出了什麼**」：
//
//	跑之前 walk 整個監看根拍一張全景快照 → 跑一輪真的同步 → 再拍一張 →
//	  ① 所有**新出現**的檔案，basename 一律要帶 MachineMark
//	  ② 所有**本來就在**的檔案，內容與 mtime 一個位元都不能變
//
// 這張網**與寫檔點的數量無關**：以後誰新增一個沒帶標記的寫檔點，不管它藏在
// 哪支檔案、哪條分支，只要它真的寫進了使用者的資料夾就會被這裡抓到。
package collector

import (
	"encoding/json"
	"io/fs"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"
)

// gemmaEchoStub＝會**看請求是哪一頁**、回傳對應那一頁合格萃取 JSON 的 Gemini 替身。
//
// 為什麼不能沿用 gemmaCardStub（固定回同一份）：這一支測試的 fixture 是「真的有
// 既有頁面的 vault」，一輪會萃好幾份（journals、pages、丟進去的原稿）。固定回同一份
// 會讓多份文件的概念卡同名互撞（wikishape 的佔用保護會擋下）⇒ 測試綠不了，
// 而那是替身的問題不是產品的問題。頁名從 wikiExtractPrompt 的「檔名：<頁名>）」裡取回來。
func gemmaEchoStub(t *testing.T) func() {
	t.Helper()
	return gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
		var req struct {
			Contents []struct {
				Parts []struct {
					Text string `json:"text"`
				} `json:"parts"`
			} `json:"contents"`
		}
		_ = json.NewDecoder(r.Body).Decode(&req)
		prompt := ""
		if len(req.Contents) > 0 && len(req.Contents[0].Parts) > 0 {
			prompt = req.Contents[0].Parts[0].Text
		}
		page := "未知頁"
		if i := strings.Index(prompt, "檔名："); i >= 0 {
			rest := prompt[i+len("檔名："):]
			if j := strings.Index(rest, "）"); j >= 0 {
				page = rest[:j]
			}
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []map[string]any{{
				"content": map[string]any{"parts": []map[string]any{
					{"text": cardFixture(page, "測試客體")},
				}},
			}},
		})
	})
}

// footprintEntry＝快照裡的一筆（內容雜湊足以同時偵測「被改了」與「被截斷了」）。
type footprintEntry struct {
	size  int64
	mtime int64
	hash  string
}

// snapshotTree walk 整個 root（含隱藏目錄——機器產物就藏在那裡），回傳 rel → 狀態。
func snapshotTree(t *testing.T, root string) map[string]footprintEntry {
	t.Helper()
	out := map[string]footprintEntry{}
	err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil || d.IsDir() {
			return err
		}
		rel, rerr := filepath.Rel(root, p)
		if rerr != nil {
			return rerr
		}
		info, ierr := d.Info()
		if ierr != nil {
			return ierr
		}
		h, herr := hashFile(p)
		if herr != nil {
			return herr
		}
		out[filepath.ToSlash(rel)] = footprintEntry{size: info.Size(), mtime: info.ModTime().UnixNano(), hash: h}
		return nil
	})
	if err != nil {
		t.Fatalf("快照失敗：%v", err)
	}
	return out
}

// sortedFootprintKeys 讓 t.Log 印出來的清單順序穩定（人要拿它當實測輸出貼進 issue）。
// 名字不叫 sortedKeys：convert_table.go 已經有一支同名的（吃 map[string][]byte），
// 同一個 package 裡不能重名。
func sortedFootprintKeys(m map[string]footprintEntry) []string {
	ks := make([]string, 0, len(m))
	for k := range m {
		ks = append(ks, k)
	}
	sort.Strings(ks)
	return ks
}

func logTree(t *testing.T, title string, snap map[string]footprintEntry) {
	t.Helper()
	t.Logf("──── %s（%d 個檔案）────", title, len(snap))
	for _, k := range sortedFootprintKeys(snap) {
		t.Logf("  %s", k)
	}
}

// newVaultWithRealJournals 造一個「真的有既有日誌檔」的 Logseq vault。
//
// 🔴 日期檔是這一票的核心：使用者的 journals/2026_08_10.md 一旦被當成原稿萃取，
// 舊版產出的卡片就叫 2026_08_10.md ⇒ 在 Logseq 眼裡跟他自己的日誌**同一頁**。
// 所以 fixture 必須真的擺日期檔，否則測不到 leo 抱怨的那一類。
func newVaultWithRealJournals(t *testing.T) (root string, preexisting map[string]string) {
	t.Helper()
	root = t.TempDir()
	mustMkdir(t, filepath.Join(root, "logseq"))
	mustMkdir(t, filepath.Join(root, "pages"))
	mustMkdir(t, filepath.Join(root, "journals"))

	preexisting = map[string]string{
		"logseq/config.edn":      "{:default-home {:page \"contents\"}}",
		"pages/contents.md":      "- 我的目錄頁",
		"pages/讀書筆記.md":          "- leo 自己寫的讀書筆記",
		"journals/2026_08_10.md": "- 今天想到的事，這是我自己寫的日記",
		"journals/2026_08_11.md": "- 昨天的日記",
	}
	for rel, body := range preexisting {
		p := filepath.Join(root, filepath.FromSlash(rel))
		mustMkdir(t, filepath.Dir(p))
		if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	return root, preexisting
}

// 驗收條件①②：在一個真的有既有日誌檔的 vault 上跑一輪，
// 新檔全部帶標記、他原本的檔一個都沒被動到。
func TestVaultFootprint_EveryNewFileIsMarked(t *testing.T) {
	root, preexisting := newVaultWithRealJournals(t)

	// 使用者丟進 vault 的原稿，**刻意用日期當檔名**——這是最會撞的那一種：
	// 舊行為會萃出 `2026_08_10.md`，跟 journals/2026_08_10.md 同頁名。
	srcRel := "2026_08_10.md"
	if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 要被萃取的原稿"), 0o644); err != nil {
		t.Fatal(err)
	}

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
	}))
	defer srv.Close()
	defer gemmaEchoStub(t)()

	before := snapshotTree(t, root)
	logTree(t, "跑之前", before)

	cfg := &DirectConfig{
		WatchFolders: []string{root},
		Manifest:     filepath.Join(t.TempDir(), "m.json"),
		CypherURL:    srv.URL, Namespace: "demo", APIKey: "demo",
		Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
		CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
	}
	results, exit, _ := RunDirectOnce(cfg, false)
	if exit != 0 {
		t.Fatalf("同步失敗 exit=%d results=%+v", exit, results)
	}

	after := snapshotTree(t, root)
	logTree(t, "跑之後", after)

	// ── ① 所有新出現的檔案，basename 一律帶標記 ──────────────────────────
	var unmarked, added []string
	for rel := range after {
		if _, existed := before[rel]; existed {
			continue
		}
		added = append(added, rel)
		if !IsMachineOwnedRel(rel) {
			unmarked = append(unmarked, rel)
		}
	}
	sort.Strings(added)
	sort.Strings(unmarked)
	t.Logf("新增的檔案：%v", added)
	if len(added) == 0 {
		t.Fatal("這一輪什麼都沒產出——紅線是「東西照樣產出、但不撞名」，沒產出等於沒測到")
	}
	if len(unmarked) > 0 {
		t.Fatalf("有 %d 個新檔沒帶 %q 標記，使用者分不出是誰寫的：%v",
			len(unmarked), MachineMark, unmarked)
	}

	// ── ② 他原本的檔，一個位元都不能變 ────────────────────────────────
	for rel, want := range before {
		got, still := after[rel]
		if !still {
			t.Fatalf("使用者原本的檔案不見了：%s", rel)
		}
		if got.hash != want.hash || got.size != want.size {
			t.Fatalf("使用者原本的檔案被改了：%s（size %d→%d）", rel, want.size, got.size)
		}
		if got.mtime != want.mtime {
			t.Fatalf("使用者原本的檔案 mtime 被動過：%s", rel)
		}
	}
	// 內容也逐字核對一次（hash 相同已足夠，這裡是給人看的雙保險）。
	for rel, want := range preexisting {
		data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
		if err != nil {
			t.Fatalf("讀不回原本的檔案 %s：%v", rel, err)
		}
		if string(data) != want {
			t.Fatalf("原本的檔案內容變了：%s\n want %q\n got  %q", rel, want, data)
		}
	}

	// ── ③ 撞名這件事本身：不准有任何機器產物的頁名等於使用者的頁名 ──────
	userPages := map[string]string{}
	for rel := range preexisting {
		if strings.HasSuffix(rel, ".md") {
			userPages[pageNameOf(rel)] = rel
		}
	}
	userPages[pageNameOf(srcRel)] = srcRel
	for _, rel := range added {
		if !strings.HasSuffix(rel, ".md") {
			continue
		}
		if owner, clash := userPages[pageNameOf(rel)]; clash {
			t.Fatalf("機器產物 %s 的頁名與使用者的 %s 撞在一起（Logseq 會當成同一頁）", rel, owner)
		}
	}
}

// 非 vault 的一般資料夾，同一條規則同樣成立。
//
// 🔴 為什麼要有這一支：紅線是「前綴只准一種，不要一部分加一部分不加」。
// 若只在 vault 加標記，一般資料夾的使用者照樣分不出哪些是機器寫的——
// 而 system-dev/wiki/cards/ 在他的檔案總管裡是**看得見的**，不像 vault 那條是隱藏目錄。
func TestPlainFolderFootprint_EveryNewFileIsMarked(t *testing.T) {
	root := t.TempDir()
	mine := filepath.Join(root, "我的報告.md")
	if err := os.WriteFile(mine, []byte("我自己的內容"), 0o644); err != nil {
		t.Fatal(err)
	}
	srcRel := "報銷規則.md"
	if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil {
		t.Fatal(err)
	}

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
	}))
	defer srv.Close()
	defer gemmaEchoStub(t)()

	before := snapshotTree(t, root)
	cfg := &DirectConfig{
		WatchFolders: []string{root},
		Manifest:     filepath.Join(t.TempDir(), "m.json"),
		CypherURL:    srv.URL, Namespace: "demo", APIKey: "demo",
		Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
		CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
	}
	if results, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
		t.Fatalf("同步失敗 exit=%d results=%+v", exit, results)
	}
	after := snapshotTree(t, root)
	logTree(t, "一般資料夾・跑之後", after)

	newCount := 0
	for rel := range after {
		if _, existed := before[rel]; existed {
			continue
		}
		newCount++
		if !IsMachineOwnedRel(rel) {
			t.Fatalf("一般資料夾也不准有沒帶標記的新檔：%s", rel)
		}
	}
	if newCount == 0 {
		t.Fatal("沒有任何產出，測不到東西")
	}
	if data, _ := os.ReadFile(mine); string(data) != "我自己的內容" {
		t.Fatalf("使用者原本的檔被動過：%q", data)
	}
}

// 佔用保護也在網內：`.wiki/` 裡「不是本文件產的」同名檔案，改寫必須被擋下、
// 原檔原封不動（InkStoneCo#44 ④：塑形層以 manifest 認擁有權，佔用＝報錯不覆蓋）。
//
// 這一條是「足跡測試比點測強」的最好例子——沒有人特地為既有檔寫過測試，
// 但那正是 daemon 最不准碰的東西。
func TestVaultFootprint_ForeignWikiFileNotClobbered(t *testing.T) {
	root, _ := newVaultWithRealJournals(t)
	srcRel := "會議記錄.md"
	if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 會議記錄\n內文 v1"), 0o644); err != nil {
		t.Fatal(err)
	}
	// 先放一個同名檔（不是 manifest 記載的產物）→ 這一輪落卡必須被擋。
	cardDir := filepath.Join(root, wikiRelDir)
	mustMkdir(t, cardDir)
	preexisting := "# 會議記錄\n這是先前就存在的內容"
	if err := os.WriteFile(filepath.Join(cardDir, "會議記錄.md"), []byte(preexisting), 0o644); err != nil {
		t.Fatal(err)
	}

	defer gemmaCardStub(t, cardFixture("會議記錄", "專案"))()
	if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel, testOrigin()); err == nil {
		t.Fatal("目標被佔用時應報錯，不得無聲覆蓋")
	}
	data, _ := os.ReadFile(filepath.Join(cardDir, "會議記錄.md"))
	if string(data) != preexisting {
		t.Fatalf("既有檔案被動過：%q", data)
	}
}
