package memory

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// TestComposeEmptyIsIdentity is the cache-first invariant: with no memory at
// all, Compose must return the base prompt byte-for-byte, so the cached system
// prefix is exactly what it was before memory existed.
func TestComposeEmptyIsIdentity(t *testing.T) {
	base := "You are a helpful coding agent.\nBe concise."
	got := Compose(base, &Set{})
	if got != base {
		t.Fatalf("empty memory changed the prompt:\n base=%q\n got =%q", base, got)
	}
	// A nil-ish set (no docs, blank index) must also be identity.
	if got := Compose(base, &Set{Index: "   \n"}); got != base {
		t.Fatalf("blank index changed the prompt: got %q", got)
	}
}

// TestComposeAppendsAfterBase verifies memory folds in *after* the base prompt,
// so the base stays a valid cache prefix even as memory changes between sessions.
func TestComposeAppendsAfterBase(t *testing.T) {
	base := "BASE PROMPT"
	set := &Set{Docs: []Source{{Path: "/p/REASONIX.md", Scope: ScopeProject, Body: "Use tabs."}}}
	got := Compose(base, set)
	if !strings.HasPrefix(got, base) {
		t.Fatalf("base is not the prefix of the composed prompt:\n%q", got)
	}
	if !strings.Contains(got, "Use tabs.") {
		t.Fatalf("doc body missing from composed prompt:\n%q", got)
	}
}

// TestDiscoverPrecedenceOrder checks user → ancestor → project → local ordering,
// which puts the most specific guidance last.
func TestDiscoverPrecedenceOrder(t *testing.T) {
	root := t.TempDir()
	user := filepath.Join(root, "userconfig")
	proj := filepath.Join(root, "proj")
	mustMkdir(t, user)
	mustMkdir(t, proj)
	// Make proj a git root so discovery stops there.
	mustMkdir(t, filepath.Join(proj, ".git"))

	mustWrite(t, filepath.Join(user, "REASONIX.md"), "USER LEVEL")
	mustWrite(t, filepath.Join(proj, "REASONIX.md"), "PROJECT LEVEL")
	mustWrite(t, filepath.Join(proj, "REASONIX.local.md"), "LOCAL LEVEL")

	set := Load(Options{CWD: proj, UserDir: user})
	if len(set.Docs) != 3 {
		t.Fatalf("want 3 docs, got %d: %+v", len(set.Docs), set.Docs)
	}
	wantScopes := []Scope{ScopeUser, ScopeProject, ScopeLocal}
	for i, s := range wantScopes {
		if set.Docs[i].Scope != s {
			t.Fatalf("doc %d: want scope %q, got %q", i, s, set.Docs[i].Scope)
		}
	}
	// In the composed block, local must appear after project must appear after user.
	block := set.Block()
	iu, ip, il := strings.Index(block, "USER LEVEL"), strings.Index(block, "PROJECT LEVEL"), strings.Index(block, "LOCAL LEVEL")
	if !(iu >= 0 && iu < ip && ip < il) {
		t.Fatalf("precedence order wrong in block: user=%d project=%d local=%d\n%s", iu, ip, il, block)
	}
}

// TestImportResolution checks "@path" inlining, including a relative import.
func TestImportResolution(t *testing.T) {
	proj := t.TempDir()
	mustMkdir(t, filepath.Join(proj, ".git"))
	mustWrite(t, filepath.Join(proj, "shared.md"), "SHARED CONTENT")
	mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top line\n@shared.md\nBottom line")

	set := Load(Options{CWD: proj})
	if len(set.Docs) != 1 {
		t.Fatalf("want 1 doc, got %d", len(set.Docs))
	}
	body := set.Docs[0].Body
	if !strings.Contains(body, "SHARED CONTENT") {
		t.Fatalf("import not inlined: %q", body)
	}
	if strings.Contains(body, "@shared.md") {
		t.Fatalf("import directive left in body: %q", body)
	}
}

// TestImportCycleDoesNotHang verifies cycle detection terminates.
func TestImportCycleDoesNotHang(t *testing.T) {
	proj := t.TempDir()
	mustMkdir(t, filepath.Join(proj, ".git"))
	mustWrite(t, filepath.Join(proj, "a.md"), "A\n@b.md")
	mustWrite(t, filepath.Join(proj, "b.md"), "B\n@a.md")
	mustWrite(t, filepath.Join(proj, "REASONIX.md"), "@a.md")

	set := Load(Options{CWD: proj}) // must return, not loop forever
	body := set.Docs[0].Body
	if !strings.Contains(body, "A") || !strings.Contains(body, "B") {
		t.Fatalf("cycle import dropped content: %q", body)
	}
}

// TestImportTargetClassification guards the "@mention vs @import" heuristic.
func TestImportTargetClassification(t *testing.T) {
	cases := []struct {
		line string
		want bool
	}{
		{"@docs/setup.md", true},
		{"@./notes.txt", true},
		{"@/abs/path.md", true},
		{"@mention", false},      // prose-y, no separator/dot
		{"@", false},             // bare
		{"@a/b and more", false}, // not the only token
		{"plain text", false},
	}
	for _, c := range cases {
		if _, got := importTarget(c.line); got != c.want {
			t.Errorf("importTarget(%q) = %v, want %v", c.line, got, c.want)
		}
	}
}

func mustMkdir(t *testing.T, dir string) {
	t.Helper()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
}

func mustWrite(t *testing.T, path, body string) {
	t.Helper()
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
}
