// extract_gemma_test.go — task 4（httptest 替身：驗 prompt 契約/thought 淨化/落卡）。
package collector

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

func gemmaStub(t *testing.T, handler http.HandlerFunc) func() {
	t.Helper()
	srv := httptest.NewServer(handler)
	old := gemmaBaseURL
	gemmaBaseURL = srv.URL
	return func() { gemmaBaseURL = old; srv.Close() }
}

// happy path：思考型回應（parts[0]=thought）→ 取最後非 thought part、撈 JSON、落規範形卡。
func TestExtractWithGemmaThinkingModel(t *testing.T) {
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, "會議記錄.md"), []byte("# 會議記錄\n\n內文"), 0o644); err != nil {
		t.Fatal(err)
	}
	defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("x-goog-api-key") != "k123" {
			t.Errorf("api key 未帶到 header")
		}
		var req map[string]any
		_ = json.NewDecoder(r.Body).Decode(&req)
		b, _ := json.Marshal(req)
		if !strings.Contains(string(b), "檔名：會議記錄") {
			t.Errorf("prompt 未帶頁名")
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []map[string]any{{
				"content": map[string]any{"parts": []map[string]any{
					{"thought": true, "text": "let me think..."},
					{"text": "草稿雜訊\n" + cardFixture("會議記錄", "專案")},
				}},
			}},
		})
	})()
	cards, err := ExtractWithGemma("k123", "gemma-test", root, "會議記錄.md", testOrigin())
	if err != nil {
		t.Fatal(err)
	}
	// 文件卡（H1＝會議記錄）＋一張概念卡，全部落 `.wiki/`
	if len(cards) != 2 || cards[0] != ".wiki/會議記錄.md" || cards[1] != ".wiki/會議記錄·概念.md" {
		t.Fatalf("cards=%v", cards)
	}
	data, _ := os.ReadFile(filepath.Join(root, ".wiki", "會議記錄.md"))
	if !strings.HasPrefix(string(data), "---\n") || !strings.Contains(string(data), "gloss: 會議記錄的測試用一句話") {
		t.Fatalf("卡片不是規範形（frontmatter+gloss）：%.120s", string(data))
	}
	if strings.Contains(string(data), "草稿雜訊") {
		t.Fatal("思考草稿洩入卡片")
	}
	for _, sec := range []string{"← [[00-INDEX]]", "## 摘要", "## 重點", "## 實體", "## 關聯", "### 出處"} {
		if !strings.Contains(string(data), sec) {
			t.Fatalf("文件卡缺「%s」：\n%s", sec, data)
		}
	}
}

// thought-only 回應＝誠實報錯。
func TestExtractWithGemmaThoughtOnly(t *testing.T) {
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, "x.md"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []map[string]any{{
				"content": map[string]any{"parts": []map[string]any{{"thought": true, "text": "..."}}},
			}},
		})
	})()
	if _, err := ExtractWithGemma("k", "m", root, "x.md", testOrigin()); err == nil {
		t.Fatal("thought-only 應報錯")
	}
}

// 缺 key＝引導訊息。
func TestExtractWithGemmaNoKey(t *testing.T) {
	if _, err := ExtractWithGemma("", "m", t.TempDir(), "x.md", testOrigin()); err == nil {
		t.Fatal("缺 key 應報錯")
	}
}

// arcrun-rag#60 驗收核心：對著一個「長得像 Logseq vault」的資料夾跑萃取，
// 卡片不能落在 pages/、journals/ 或任何非隱藏目錄——vault 的頁面數不能因為
// daemon 跑過而增加。
func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) {
	root := t.TempDir()
	// 造一個像真的 Logseq vault：有 logseq/、pages/、journals/，journals 裡放一篇
	// leo 自己的日記（模擬「原稿」，萃取對象另外放在 vault 根目錄下）。
	mustMkdir(t, filepath.Join(root, "logseq"))
	mustMkdir(t, filepath.Join(root, "pages"))
	mustMkdir(t, filepath.Join(root, "journals"))
	journalPath := filepath.Join(root, "journals", "2026_08_08.md")
	journalContent := "leo 原話原圖，不該被動"
	if err := os.WriteFile(journalPath, []byte(journalContent), 0o644); err != nil {
		t.Fatal(err)
	}
	// 監看到的來源檔（相對 vault 根）——模擬使用者丟進 vault 的一份原稿。
	srcRel := "會議記錄.md"
	if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil {
		t.Fatal(err)
	}

	defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []map[string]any{{
				"content": map[string]any{"parts": []map[string]any{
					{"text": cardFixture("會議記錄", "專案")},
				}},
			}},
		})
	})()

	pagesBefore := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root)

	cards, err := ExtractWithGemma("k123", "gemma-test", root, srcRel, testOrigin())
	if err != nil {
		t.Fatal(err)
	}
	// 原稿內容「# 原稿」⇒ 文件卡名＝H1「原稿」；`.wiki/` 是隱藏目錄，Logseq 不掃
	if len(cards) != 2 || cards[0] != ".wiki/原稿.md" {
		t.Fatalf("vault 目標的卡片路徑不對：%v，want [.wiki/原稿.md …]", cards)
	}

	pagesAfter := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root)
	if pagesAfter != pagesBefore {
		t.Fatalf("vault 頁面數增加了：before=%d after=%d（daemon 跑完不該讓 Logseq 多任何頁）", pagesBefore, pagesAfter)
	}

	// 原稿（journals 裡 leo 的日記）必須原封不動。
	got, err := os.ReadFile(journalPath)
	if err != nil {
		t.Fatal(err)
	}
	if string(got) != journalContent {
		t.Fatalf("journals 原稿被動過：%q", got)
	}

	// 卡片確實落在隱藏目錄，且是「監看根底下」（呼叫端 absRoot-relative 假設仍成立）。
	cardAbs := filepath.Join(root, ".wiki", "原稿.md")
	if _, err := os.Stat(cardAbs); err != nil {
		t.Fatalf("卡片沒有落在預期的隱藏目錄：%v", err)
	}
}

// 故意在 `.wiki/` 先放一個「不是本文件產的」同名檔案：不覆蓋、誠實報錯、原檔原封不動
// （#105 的分界：本來就在的檔案一律不動——wikishape 的 writeCard 以 manifest 認擁有權）。
func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) {
	root := t.TempDir()
	mustMkdir(t, filepath.Join(root, "logseq"))
	srcRel := "x.md"
	if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 既有主題\n內文"), 0o644); err != nil {
		t.Fatal(err)
	}
	cardDir := filepath.Join(root, ".wiki")
	mustMkdir(t, cardDir)
	preexisting := "# 既有主題\n這份是先前就存在的內容"
	cardPath := filepath.Join(cardDir, "既有主題.md")
	if err := os.WriteFile(cardPath, []byte(preexisting), 0o644); err != nil {
		t.Fatal(err)
	}

	defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []map[string]any{{
				"content": map[string]any{"parts": []map[string]any{
					{"text": cardFixture("既有主題", "專案")},
				}},
			}},
		})
	})()

	_, err := ExtractWithGemma("k123", "gemma-test", root, srcRel, testOrigin())
	if err == nil {
		t.Fatal("目標位置被別人佔用時應報錯，不得無聲覆蓋")
	}
	if !strings.Contains(err.Error(), "佔用") {
		t.Fatalf("錯誤訊息看不出原因：%v", err)
	}
	data, _ := os.ReadFile(cardPath)
	if string(data) != preexisting {
		t.Fatalf("既有檔案被動過：%q", data)
	}
}

func countMD(t *testing.T, dir string) int {
	t.Helper()
	n := 0
	entries, err := os.ReadDir(dir)
	if err != nil {
		return 0
	}
	for _, e := range entries {
		if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") {
			n++
		}
	}
	return n
}

func countTopLevelMD(t *testing.T, dir string) int {
	return countMD(t, dir)
}
