package mcp

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"testing"

	mcplib "github.com/mark3labs/mcp-go/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/zap"
	"pgregory.net/rapid"

	"github.com/zzet/gortex/internal/config"
	"github.com/zzet/gortex/internal/graph"
	"github.com/zzet/gortex/internal/query"
)

// ============================================================================
// 10.2 Property 21: Config persistence round-trip
// ============================================================================

// Feature: multi-repo-support, Property 21: Config persistence round-trip
//
// For any sequence of track and untrack operations, the GlobalConfig file
// SHALL reflect the current state of the repos list. After tracking a repo,
// the config file SHALL contain that entry. After untracking, the entry
// SHALL be removed.
func TestPropertyConfigPersistenceRoundTrip(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		// Create a temp directory for the GlobalConfig file.
		dir := t.TempDir()
		configPath := filepath.Join(dir, "config.yaml")

		// Start with an empty GlobalConfig.
		gc := &config.GlobalConfig{}
		gc.SetConfigPath(configPath)
		require.NoError(t, gc.Save())

		// Generate a sequence of track/untrack operations.
		// Use absolute paths within the temp dir so AddRepo/RemoveRepo work.
		numOps := rapid.IntRange(1, 30).Draw(rt, "numOps")
		tracked := make(map[string]bool) // absPath → tracked

		for i := 0; i < numOps; i++ {
			// Generate a repo path (use temp dir subdirectories as absolute paths).
			repoName := rapid.StringMatching(`[a-z]{2,8}`).Draw(rt, "repoName")
			repoPath := filepath.Join(dir, "repos", repoName)

			isTrack := rapid.Bool().Draw(rt, "isTrack")

			if isTrack {
				// Create the directory so AddRepo can resolve it.
				_ = os.MkdirAll(repoPath, 0o755)
				entry := config.RepoEntry{Path: repoPath}
				_ = gc.AddRepo(entry)
				tracked[repoPath] = true
			} else {
				// Untrack: try to remove. If it wasn't tracked, RemoveRepo returns error — that's fine.
				err := gc.RemoveRepo(repoPath)
				if err == nil {
					delete(tracked, repoPath)
				}
			}

			// Save after each operation.
			require.NoError(t, gc.Save())

			// Reload from disk and verify state matches.
			loaded, err := config.LoadGlobal(configPath)
			require.NoError(t, err)

			// Build set of loaded paths.
			loadedPaths := make(map[string]bool)
			for _, r := range loaded.Repos {
				loadedPaths[r.Path] = true
			}

			// Verify: every tracked path is in the loaded config.
			for path := range tracked {
				if !loadedPaths[path] {
					rt.Errorf("op %d: tracked path %s not found in reloaded config", i, path)
				}
			}

			// Verify: every loaded path is in the tracked set.
			for path := range loadedPaths {
				if !tracked[path] {
					rt.Errorf("op %d: loaded path %s not in tracked set", i, path)
				}
			}

			// Verify counts match.
			if len(loaded.Repos) != len(tracked) {
				rt.Errorf("op %d: loaded %d repos, expected %d", i, len(loaded.Repos), len(tracked))
			}
		}
	})
}

// ============================================================================
// 10.4 Property 14: Query scoping by repo, project, and ref
// ============================================================================

// Feature: multi-repo-support, Property 14: Query scoping by repo, project, and ref
//
// For any multi-repo graph and any query with a repo parameter, results SHALL
// contain only nodes with matching RepoPrefix. When project is specified,
// results SHALL contain only nodes from repos belonging to that project.
// When ref is specified with project, results SHALL contain only nodes from
// repos in that project with matching reference tag. When ref is specified
// without project, results SHALL contain nodes from all repos across all
// projects with matching reference tag. When no filter is specified, results
// SHALL span all repositories.
func TestPropertyQueryScopingByRepoProjectRef(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		// Generate 2-5 repos with distinct prefixes.
		numRepos := rapid.IntRange(2, 5).Draw(rt, "numRepos")
		prefixes := make([]string, numRepos)
		for i := 0; i < numRepos; i++ {
			prefixes[i] = rapid.StringMatching(`[a-z]{3,8}`).Draw(rt, "prefix") +
				rapid.StringMatching(`[0-9]{1,2}`).Draw(rt, "suffix")
		}
		// Deduplicate prefixes.
		seen := make(map[string]bool)
		var uniquePrefixes []string
		for _, p := range prefixes {
			if !seen[p] {
				seen[p] = true
				uniquePrefixes = append(uniquePrefixes, p)
			}
		}
		if len(uniquePrefixes) < 2 {
			return // need at least 2 distinct repos
		}
		prefixes = uniquePrefixes

		// Build a graph with nodes from each repo.
		g := graph.New()
		repoNodes := make(map[string][]string) // prefix → node IDs
		for _, prefix := range prefixes {
			numNodes := rapid.IntRange(1, 5).Draw(rt, "numNodes_"+prefix)
			for j := 0; j < numNodes; j++ {
				id := prefix + "/file.go::Func" + rapid.StringMatching(`[A-Z][a-z]{2,6}`).Draw(rt, "funcName")
				node := &graph.Node{
					ID:         id,
					Kind:       graph.KindFunction,
					Name:       "Func",
					FilePath:   prefix + "/file.go",
					StartLine:  j + 1,
					Language:   "go",
					RepoPrefix: prefix,
				}
				g.AddNode(node)
				repoNodes[prefix] = append(repoNodes[prefix], id)
			}
		}

		// Create projects with ref tags.
		// Project "alpha" gets first half of repos, "beta" gets second half.
		// Some repos may overlap (shared repos).
		mid := len(prefixes) / 2
		if mid == 0 {
			mid = 1
		}
		alphaRepos := prefixes[:mid]
		betaRepos := prefixes[mid:]

		// Assign ref tags.
		alphaEntries := make([]config.RepoEntry, len(alphaRepos))
		for i, p := range alphaRepos {
			alphaEntries[i] = config.RepoEntry{Path: "/tmp/" + p, Name: p, Ref: "work"}
		}
		betaEntries := make([]config.RepoEntry, len(betaRepos))
		for i, p := range betaRepos {
			betaEntries[i] = config.RepoEntry{Path: "/tmp/" + p, Name: p, Ref: "opensource"}
		}

		gc := &config.GlobalConfig{
			Projects: map[string]config.ProjectConfig{
				"alpha": {Repos: alphaEntries},
				"beta":  {Repos: betaEntries},
			},
		}

		dir := t.TempDir()
		gcPath := filepath.Join(dir, "config.yaml")
		gc.SetConfigPath(gcPath)
		require.NoError(t, gc.Save())

		cm, err := config.NewConfigManager(gcPath)
		require.NoError(t, err)

		eng := query.NewEngine(g)
		srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{
			ConfigManager: cm,
		})

		// --- Test 1: repo filter ---
		targetPrefix := rapid.SampledFrom(prefixes).Draw(rt, "targetRepo")
		req1 := mcplib.CallToolRequest{}
		req1.Params.Arguments = map[string]any{"repo": targetPrefix}
		allowed1, err := srv.resolveRepoFilter(context.Background(), req1)
		require.NoError(t, err)
		require.NotNil(t, allowed1, "repo filter should return non-nil allowed set")
		assert.True(t, allowed1[targetPrefix], "target repo should be in allowed set")
		assert.Equal(t, 1, len(allowed1), "repo filter should allow exactly one prefix")

		// Verify filtering nodes works correctly.
		allNodes := g.AllNodes()
		filtered1 := filterNodes(allNodes, allowed1)
		for _, n := range filtered1 {
			assert.Equal(t, targetPrefix, n.RepoPrefix,
				"filtered node should have matching RepoPrefix")
		}

		// --- Test 2: project filter ---
		targetProject := rapid.SampledFrom([]string{"alpha", "beta"}).Draw(rt, "targetProject")
		req2 := mcplib.CallToolRequest{}
		req2.Params.Arguments = map[string]any{"project": targetProject}
		allowed2, err := srv.resolveRepoFilter(context.Background(), req2)
		require.NoError(t, err)
		require.NotNil(t, allowed2)

		var expectedPrefixes []string
		if targetProject == "alpha" {
			expectedPrefixes = alphaRepos
		} else {
			expectedPrefixes = betaRepos
		}
		for _, p := range expectedPrefixes {
			assert.True(t, allowed2[p],
				"project %s should include repo %s", targetProject, p)
		}

		filtered2 := filterNodes(allNodes, allowed2)
		for _, n := range filtered2 {
			assert.True(t, allowed2[n.RepoPrefix],
				"filtered node repo %s should be in project %s", n.RepoPrefix, targetProject)
		}

		// --- Test 3: project + ref filter ---
		req3 := mcplib.CallToolRequest{}
		req3.Params.Arguments = map[string]any{"project": "alpha", "ref": "work"}
		allowed3, err := srv.resolveRepoFilter(context.Background(), req3)
		require.NoError(t, err)
		require.NotNil(t, allowed3)
		for _, p := range alphaRepos {
			assert.True(t, allowed3[p],
				"alpha+work should include repo %s", p)
		}
		// Beta repos should NOT be in the result.
		for _, p := range betaRepos {
			assert.False(t, allowed3[p],
				"alpha+work should NOT include beta repo %s", p)
		}

		// --- Test 4: ref without project ---
		req4 := mcplib.CallToolRequest{}
		req4.Params.Arguments = map[string]any{"ref": "opensource"}
		allowed4, err := srv.resolveRepoFilter(context.Background(), req4)
		require.NoError(t, err)
		require.NotNil(t, allowed4)
		for _, p := range betaRepos {
			assert.True(t, allowed4[p],
				"ref=opensource should include beta repo %s", p)
		}
		for _, p := range alphaRepos {
			assert.False(t, allowed4[p],
				"ref=opensource should NOT include alpha repo %s (ref=work)", p)
		}

		// --- Test 5: no filter → nil (all repos) ---
		req5 := mcplib.CallToolRequest{}
		req5.Params.Arguments = map[string]any{}
		allowed5, err := srv.resolveRepoFilter(context.Background(), req5)
		require.NoError(t, err)
		assert.Nil(t, allowed5, "no filter should return nil (all repos)")
	})
}

// ============================================================================
// 10.7 Property 20: Active project default scoping
// ============================================================================

// Feature: multi-repo-support, Property 20: Active project default scoping
//
// For any active project setting, all MCP tool calls without an explicit
// project parameter SHALL return results scoped to the active project's
// repositories. Changing the active project via set_active_project SHALL
// immediately change the default scope for all subsequent queries.
func TestPropertyActiveProjectDefaultScoping(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		// Generate 2-4 repos with distinct prefixes.
		numRepos := rapid.IntRange(2, 4).Draw(rt, "numRepos")
		prefixes := make([]string, numRepos)
		for i := 0; i < numRepos; i++ {
			prefixes[i] = rapid.StringMatching(`[a-z]{3,6}`).Draw(rt, "prefix") +
				rapid.StringMatching(`[0-9]{1,2}`).Draw(rt, "suffix")
		}
		// Deduplicate.
		seen := make(map[string]bool)
		var unique []string
		for _, p := range prefixes {
			if !seen[p] {
				seen[p] = true
				unique = append(unique, p)
			}
		}
		if len(unique) < 2 {
			return
		}
		prefixes = unique

		// Build graph with nodes from each repo.
		g := graph.New()
		for _, prefix := range prefixes {
			for j := 0; j < 3; j++ {
				id := prefix + "/file.go::Func" + rapid.StringMatching(`[A-Z][a-z]{2,5}`).Draw(rt, "fn")
				g.AddNode(&graph.Node{
					ID:         id,
					Kind:       graph.KindFunction,
					Name:       "Func",
					FilePath:   prefix + "/file.go",
					StartLine:  j + 1,
					Language:   "go",
					RepoPrefix: prefix,
				})
			}
		}

		// Split repos into two projects.
		mid := len(prefixes) / 2
		if mid == 0 {
			mid = 1
		}
		projARepos := prefixes[:mid]
		projBRepos := prefixes[mid:]

		projAEntries := make([]config.RepoEntry, len(projARepos))
		for i, p := range projARepos {
			projAEntries[i] = config.RepoEntry{Path: "/tmp/" + p, Name: p}
		}
		projBEntries := make([]config.RepoEntry, len(projBRepos))
		for i, p := range projBRepos {
			projBEntries[i] = config.RepoEntry{Path: "/tmp/" + p, Name: p}
		}

		gc := &config.GlobalConfig{
			Projects: map[string]config.ProjectConfig{
				"projA": {Repos: projAEntries},
				"projB": {Repos: projBEntries},
			},
		}

		dir := t.TempDir()
		gcPath := filepath.Join(dir, "config.yaml")
		gc.SetConfigPath(gcPath)
		require.NoError(t, gc.Save())

		cm, err := config.NewConfigManager(gcPath)
		require.NoError(t, err)

		eng := query.NewEngine(g)

		// --- Phase 1: Set active project to projA ---
		srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{
			ConfigManager: cm,
			ActiveProject: "projA",
		})

		// Call resolveRepoFilter with empty params — should scope to projA.
		reqEmpty := mcplib.CallToolRequest{}
		reqEmpty.Params.Arguments = map[string]any{}
		allowed, err := srv.resolveRepoFilter(context.Background(), reqEmpty)
		require.NoError(t, err)
		require.NotNil(t, allowed, "active project should produce non-nil filter")

		for _, p := range projARepos {
			assert.True(t, allowed[p],
				"active project projA: repo %s should be in scope", p)
		}
		for _, p := range projBRepos {
			assert.False(t, allowed[p],
				"active project projA: repo %s should NOT be in scope", p)
		}

		// Verify node filtering matches.
		allNodes := g.AllNodes()
		filtered := filterNodes(allNodes, allowed)
		for _, n := range filtered {
			assert.True(t, allowed[n.RepoPrefix],
				"filtered node %s should be in projA scope", n.ID)
		}

		// --- Phase 2: Switch active project to projB ---
		srv.activeProject = "projB"

		allowed2, err := srv.resolveRepoFilter(context.Background(), reqEmpty)
		require.NoError(t, err)
		require.NotNil(t, allowed2, "active project projB should produce non-nil filter")

		for _, p := range projBRepos {
			assert.True(t, allowed2[p],
				"active project projB: repo %s should be in scope", p)
		}
		for _, p := range projARepos {
			assert.False(t, allowed2[p],
				"active project projB: repo %s should NOT be in scope", p)
		}

		// --- Phase 3: Explicit project param overrides active project ---
		reqExplicit := mcplib.CallToolRequest{}
		reqExplicit.Params.Arguments = map[string]any{"project": "projA"}
		allowed3, err := srv.resolveRepoFilter(context.Background(), reqExplicit)
		require.NoError(t, err)
		require.NotNil(t, allowed3)

		for _, p := range projARepos {
			assert.True(t, allowed3[p],
				"explicit project=projA should include repo %s even when active is projB", p)
		}
	})
}

// ============================================================================
// 10.8 Property 15: Shared repo indexed once
// ============================================================================

// Feature: multi-repo-support, Property 15: Shared repo indexed once
//
// For any repository path that appears in multiple project definitions,
// the graph SHALL contain exactly one set of nodes for that repository
// (not duplicated per project). The node count for that RepoPrefix SHALL
// equal the count from a single indexing of that repository.
func TestPropertySharedRepoIndexedOnce(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		// Generate a shared repo prefix and 1-2 unique repo prefixes.
		sharedPrefix := rapid.StringMatching(`shared[a-z]{1,4}`).Draw(rt, "sharedPrefix")
		numUnique := rapid.IntRange(1, 3).Draw(rt, "numUnique")
		uniquePrefixes := make([]string, numUnique)
		for i := 0; i < numUnique; i++ {
			uniquePrefixes[i] = rapid.StringMatching(`unique[a-z]{1,4}`).Draw(rt, "uniquePrefix") +
				rapid.StringMatching(`[0-9]{1,2}`).Draw(rt, "suffix")
		}

		// Generate nodes for the shared repo.
		sharedNodeCount := rapid.IntRange(2, 8).Draw(rt, "sharedNodeCount")
		g := graph.New()

		// Add shared repo nodes exactly once (simulating correct indexing behavior).
		// Use index-based names to guarantee uniqueness.
		for j := 0; j < sharedNodeCount; j++ {
			funcName := fmt.Sprintf("SharedFunc%d", j)
			id := sharedPrefix + "/file.go::" + funcName
			g.AddNode(&graph.Node{
				ID:         id,
				Kind:       graph.KindFunction,
				Name:       funcName,
				FilePath:   sharedPrefix + "/file.go",
				StartLine:  j + 1,
				Language:   "go",
				RepoPrefix: sharedPrefix,
			})
		}

		// Add unique repo nodes.
		for _, prefix := range uniquePrefixes {
			numNodes := rapid.IntRange(1, 5).Draw(rt, "numNodes_"+prefix)
			for j := 0; j < numNodes; j++ {
				funcName := fmt.Sprintf("UniqueFunc%d", j)
				id := prefix + "/file.go::" + funcName
				g.AddNode(&graph.Node{
					ID:         id,
					Kind:       graph.KindFunction,
					Name:       funcName,
					FilePath:   prefix + "/file.go",
					StartLine:  j + 1,
					Language:   "go",
					RepoPrefix: prefix,
				})
			}
		}

		// Create a GlobalConfig where the shared repo appears in multiple projects.
		numProjects := rapid.IntRange(2, 4).Draw(rt, "numProjects")
		projects := make(map[string]config.ProjectConfig, numProjects)
		for i := 0; i < numProjects; i++ {
			projName := rapid.StringMatching(`proj[a-z]{1,4}`).Draw(rt, "projName") +
				rapid.StringMatching(`[0-9]{1,2}`).Draw(rt, "projSuffix")
			entries := []config.RepoEntry{
				{Path: "/tmp/" + sharedPrefix, Name: sharedPrefix},
			}
			// Optionally add a unique repo to this project.
			if len(uniquePrefixes) > 0 {
				idx := rapid.IntRange(0, len(uniquePrefixes)-1).Draw(rt, "uniqueIdx")
				entries = append(entries, config.RepoEntry{
					Path: "/tmp/" + uniquePrefixes[idx],
					Name: uniquePrefixes[idx],
				})
			}
			projects[projName] = config.ProjectConfig{Repos: entries}
		}

		// Verify: GetRepoNodes returns exactly the shared nodes (not duplicated).
		sharedNodes := g.GetRepoNodes(sharedPrefix)
		assert.Equal(t, sharedNodeCount, len(sharedNodes),
			"shared repo should have exactly %d nodes, got %d", sharedNodeCount, len(sharedNodes))

		// Verify: each shared node appears exactly once in the graph.
		allNodes := g.AllNodes()
		sharedInGraph := 0
		for _, n := range allNodes {
			if n.RepoPrefix == sharedPrefix {
				sharedInGraph++
			}
		}
		assert.Equal(t, sharedNodeCount, sharedInGraph,
			"shared repo nodes in full graph should be %d, got %d", sharedNodeCount, sharedInGraph)

		// Verify: querying from different projects returns the same shared nodes.
		// Both projects should see the shared repo's nodes when filtering.
		for projName, proj := range projects {
			allowedPrefixes := make(map[string]bool)
			for _, e := range proj.Repos {
				allowedPrefixes[config.ResolvePrefix(e)] = true
			}

			filtered := filterNodes(allNodes, allowedPrefixes)
			sharedInFiltered := 0
			for _, n := range filtered {
				if n.RepoPrefix == sharedPrefix {
					sharedInFiltered++
				}
			}
			assert.Equal(t, sharedNodeCount, sharedInFiltered,
				"project %s should see exactly %d shared nodes, got %d",
				projName, sharedNodeCount, sharedInFiltered)
		}
	})
}
