package mcp

import (
	"encoding/json"
	"os"
	"path/filepath"
	"testing"

	mcplib "github.com/mark3labs/mcp-go/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func decodeFileOpsResult(t *testing.T, result *mcplib.CallToolResult) map[string]any {
	t.Helper()
	require.NotEmpty(t, result.Content)
	text := result.Content[0].(mcplib.TextContent).Text
	var resp map[string]any
	require.NoError(t, json.Unmarshal([]byte(text), &resp))
	return resp
}

func TestWriteFile_CreatesNewFile(t *testing.T) {
	srv, dir := setupTestServer(t)

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    "docs/intro.md",
		"content": "# Intro\n\nHello world.\n",
	})
	assert.False(t, result.IsError)
	resp := decodeFileOpsResult(t, result)
	assert.Equal(t, "created", resp["status"])

	got, err := os.ReadFile(filepath.Join(dir, "docs", "intro.md"))
	require.NoError(t, err)
	assert.Equal(t, "# Intro\n\nHello world.\n", string(got))
	assert.Equal(t, float64(len(got)), resp["bytes_written"])
}

func TestWriteFile_OverwritesExistingFile(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "notes.txt")
	require.NoError(t, os.WriteFile(target, []byte("old"), 0o644))

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    "notes.txt",
		"content": "new content",
	})
	assert.False(t, result.IsError)
	resp := decodeFileOpsResult(t, result)
	assert.Equal(t, "overwritten", resp["status"])

	got, err := os.ReadFile(target)
	require.NoError(t, err)
	assert.Equal(t, "new content", string(got))
}

func TestWriteFile_AbsolutePath(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "absolute.txt")

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    target,
		"content": "abs",
	})
	assert.False(t, result.IsError)

	got, err := os.ReadFile(target)
	require.NoError(t, err)
	assert.Equal(t, "abs", string(got))
}

func TestWriteFile_ReindexesGoSource(t *testing.T) {
	srv, _ := setupTestServer(t)

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    "main.go",
		"content": "package main\n\nfunc freshlyAdded() {}\n",
	})
	assert.False(t, result.IsError)
	resp := decodeFileOpsResult(t, result)
	assert.Equal(t, true, resp["reindexed"], "Go file inside repo must trigger reindex")

	search := callTool(t, srv, "search_symbols", map[string]any{"query": "freshlyAdded"})
	searchResp := decodeFileOpsResult(t, search)
	assert.Greater(t, searchResp["total"], float64(0), "new symbol should be searchable post-reindex")
}

func TestEditFile_UniqueReplacement(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "changelog.md")
	require.NoError(t, os.WriteFile(target, []byte("v0.1 released\nv0.2 pending\n"), 0o644))

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":       "changelog.md",
		"old_string": "v0.2 pending",
		"new_string": "v0.2 shipped",
	})
	assert.False(t, result.IsError)
	resp := decodeFileOpsResult(t, result)
	assert.Equal(t, "applied", resp["status"])
	assert.Equal(t, float64(1), resp["replacements"])

	got, err := os.ReadFile(target)
	require.NoError(t, err)
	assert.Equal(t, "v0.1 released\nv0.2 shipped\n", string(got))
}

func TestEditFile_AmbiguousMatchRejected(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "dup.md")
	require.NoError(t, os.WriteFile(target, []byte("TODO\nTODO\n"), 0o644))

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":       "dup.md",
		"old_string": "TODO",
		"new_string": "DONE",
	})
	assert.True(t, result.IsError, "multiple matches without replace_all must error")

	got, err := os.ReadFile(target)
	require.NoError(t, err)
	assert.Equal(t, "TODO\nTODO\n", string(got), "file must be untouched on error")
}

func TestEditFile_ReplaceAll(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "dup.md")
	require.NoError(t, os.WriteFile(target, []byte("TODO\nTODO\nTODO\n"), 0o644))

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":        "dup.md",
		"old_string":  "TODO",
		"new_string":  "DONE",
		"replace_all": true,
	})
	assert.False(t, result.IsError)
	resp := decodeFileOpsResult(t, result)
	assert.Equal(t, float64(3), resp["replacements"])

	got, err := os.ReadFile(target)
	require.NoError(t, err)
	assert.Equal(t, "DONE\nDONE\nDONE\n", string(got))
}

func TestEditFile_MissingOldString(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "file.md")
	require.NoError(t, os.WriteFile(target, []byte("content"), 0o644))

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":       "file.md",
		"old_string": "not present",
		"new_string": "x",
	})
	assert.True(t, result.IsError)
}

func TestEditFile_IdenticalStringsRejected(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "file.md")
	require.NoError(t, os.WriteFile(target, []byte("content"), 0o644))

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":       "file.md",
		"old_string": "content",
		"new_string": "content",
	})
	assert.True(t, result.IsError)
}

func TestEditFile_NonexistentFile(t *testing.T) {
	srv, _ := setupTestServer(t)

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":       "does-not-exist.md",
		"old_string": "a",
		"new_string": "b",
	})
	assert.True(t, result.IsError)
}

func TestWriteFile_PreservesExistingMode(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "exec.sh")
	require.NoError(t, os.WriteFile(target, []byte("old"), 0o755))

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    "exec.sh",
		"content": "new",
	})
	assert.False(t, result.IsError)

	info, err := os.Stat(target)
	require.NoError(t, err)
	assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
}

func TestWriteFile_CreatesNestedDirs(t *testing.T) {
	srv, dir := setupTestServer(t)

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    "deeply/nested/sub/dir/notes.md",
		"content": "hi",
	})
	assert.False(t, result.IsError, "atomic-write must mkdir -p parent dirs")

	got, err := os.ReadFile(filepath.Join(dir, "deeply", "nested", "sub", "dir", "notes.md"))
	require.NoError(t, err)
	assert.Equal(t, "hi", string(got))
}

func TestWriteFile_RejectsPathTraversal(t *testing.T) {
	srv, dir := setupTestServer(t)
	// Marker file outside the repo root that the traversal would target.
	parent := filepath.Dir(dir)
	probe := filepath.Join(parent, "escape.txt")
	t.Cleanup(func() { _ = os.Remove(probe) })

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    "../escape.txt",
		"content": "should not land",
	})
	assert.True(t, result.IsError, "relative path with .. that escapes repo must be refused")

	_, err := os.Stat(probe)
	assert.True(t, os.IsNotExist(err), "no file may be written outside the repo root via traversal")
}

func TestEditFile_RejectsPathTraversal(t *testing.T) {
	srv, dir := setupTestServer(t)
	parent := filepath.Dir(dir)
	probe := filepath.Join(parent, "outside.txt")
	require.NoError(t, os.WriteFile(probe, []byte("untouched"), 0o644))
	t.Cleanup(func() { _ = os.Remove(probe) })

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":       "../outside.txt",
		"old_string": "untouched",
		"new_string": "TAMPERED",
	})
	assert.True(t, result.IsError, "edit_file must refuse to follow traversal out of the repo")

	got, err := os.ReadFile(probe)
	require.NoError(t, err)
	assert.Equal(t, "untouched", string(got), "the file outside the repo must remain unchanged")
}

func TestWriteFile_AbsolutePathOutsideRepoIsAllowed(t *testing.T) {
	// Absolute paths bypass the containment check by design — agents
	// that hand over an absolute path are responsible for the location.
	srv, _ := setupTestServer(t)
	tmp := t.TempDir()
	target := filepath.Join(tmp, "external.txt")

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    target,
		"content": "abs allowed",
	})
	assert.False(t, result.IsError)

	got, err := os.ReadFile(target)
	require.NoError(t, err)
	assert.Equal(t, "abs allowed", string(got))
}

func TestEditFile_DryRunDoesNotWrite(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "preview.md")
	require.NoError(t, os.WriteFile(target, []byte("original"), 0o644))

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":       "preview.md",
		"old_string": "original",
		"new_string": "preview-only",
		"dry_run":    true,
	})
	assert.False(t, result.IsError)
	resp := decodeFileOpsResult(t, result)
	assert.Equal(t, "would_apply", resp["status"])
	assert.Equal(t, true, resp["dry_run"])
	assert.Equal(t, float64(1), resp["replacements"])
	assert.Equal(t, false, resp["reindexed"], "dry_run must NOT reindex")

	got, err := os.ReadFile(target)
	require.NoError(t, err)
	assert.Equal(t, "original", string(got), "dry_run must NOT touch the file")
}

func TestWriteFile_DryRunReportsCreate(t *testing.T) {
	srv, dir := setupTestServer(t)

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    "newfile.md",
		"content": "preview",
		"dry_run": true,
	})
	assert.False(t, result.IsError)
	resp := decodeFileOpsResult(t, result)
	assert.Equal(t, "would_create", resp["status"])
	assert.Equal(t, true, resp["dry_run"])

	_, err := os.Stat(filepath.Join(dir, "newfile.md"))
	assert.True(t, os.IsNotExist(err), "dry_run must NOT create the file")
}

func TestWriteFile_DryRunReportsOverwrite(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "existing.md")
	require.NoError(t, os.WriteFile(target, []byte("v1"), 0o644))

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    "existing.md",
		"content": "v2",
		"dry_run": true,
	})
	assert.False(t, result.IsError)
	resp := decodeFileOpsResult(t, result)
	assert.Equal(t, "would_overwrite", resp["status"])

	got, err := os.ReadFile(target)
	require.NoError(t, err)
	assert.Equal(t, "v1", string(got), "dry_run must NOT overwrite")
}

func TestWriteFile_RejectsDirectoryTarget(t *testing.T) {
	srv, dir := setupTestServer(t)
	require.NoError(t, os.MkdirAll(filepath.Join(dir, "is_a_dir"), 0o755))

	result := callTool(t, srv, "write_file", map[string]any{
		"path":    "is_a_dir",
		"content": "should fail",
	})
	assert.True(t, result.IsError, "write_file must refuse to clobber a directory")
}

func TestEditFile_AmbiguousErrorIncludesLineNumbers(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "many.md")
	require.NoError(t, os.WriteFile(target, []byte("\nTODO\nTODO\nTODO\n"), 0o644))

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":       "many.md",
		"old_string": "TODO",
		"new_string": "DONE",
	})
	assert.True(t, result.IsError)
	require.NotEmpty(t, result.Content)
	text := result.Content[0].(mcplib.TextContent).Text
	assert.Contains(t, text, "first match lines", "error must surface the line numbers so the agent can choose a unique fragment")
}

func TestEditFile_DryRunAmbiguousFailsBeforeWrite(t *testing.T) {
	srv, dir := setupTestServer(t)
	target := filepath.Join(dir, "amb.md")
	require.NoError(t, os.WriteFile(target, []byte("DUP\nDUP\n"), 0o644))

	result := callTool(t, srv, "edit_file", map[string]any{
		"path":       "amb.md",
		"old_string": "DUP",
		"new_string": "x",
		"dry_run":    true,
	})
	assert.True(t, result.IsError, "dry_run must still surface ambiguity errors")
}

func TestPathContainedIn(t *testing.T) {
	tests := []struct {
		abs, root string
		want      bool
	}{
		{"/repo/a/b.go", "/repo", true},
		{"/repo", "/repo", true},
		{"/repo/", "/repo", true},
		{"/repo-other/x.go", "/repo", false},
		{"/etc/passwd", "/repo", false},
		{"/repo/../etc/passwd", "/repo", false},
		{"", "/repo", false},
		{"/repo/x.go", "", false},
	}
	for _, tt := range tests {
		got := pathContainedIn(tt.abs, tt.root)
		assert.Equal(t, tt.want, got, "pathContainedIn(%q, %q)", tt.abs, tt.root)
	}
}
