package mcp

// Integration tests for preview_edit and simulate_chain. These hit the
// full MCP tool dispatch path (registered tools + handler) and rely on
// the shared setupOverlayServer harness defined in overlay_e2e_test.go.

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/zzet/gortex/internal/daemon"
)

// buildSingleFileEdit constructs a WorkspaceEdit that replaces the
// full body of `path` with `newContent`. The range spans (0,0) ..
// (very-far-end), which lspPositionToByteOffset clamps to len(content).
// Used by every test below so the WorkspaceEdit JSON is constructed
// in one place.
func buildSingleFileEdit(path, newContent string) string {
	edit := map[string]any{
		"documentChanges": []any{
			map[string]any{
				"textDocument": map[string]any{"uri": path, "version": 0},
				"edits": []any{
					map[string]any{
						"range": map[string]any{
							"start": map[string]any{"line": 0, "character": 0},
							"end":   map[string]any{"line": 100000, "character": 0},
						},
						"newText": newContent,
					},
				},
			},
		},
	}
	b, _ := json.Marshal(edit)
	return string(b)
}

// TestPreviewEdit_ToolRegistered confirms the tool wired up correctly
// after NewServer + SetOverlayManager. Without registration every
// subsequent simulation test would explode in the lookup; isolating
// the registration check lets us read the failure faster.
func TestPreviewEdit_ToolRegistered(t *testing.T) {
	srv, _, _, _ := setupOverlayServer(t)
	require.NotNil(t, srv.MCPServer().GetTool("preview_edit"), "preview_edit must be registered")
	require.NotNil(t, srv.MCPServer().GetTool("simulate_chain"), "simulate_chain must be registered")
}

// TestPreviewEdit_RejectsInvalidJSON: malformed workspace_edit must
// surface as an MCP tool error so the agent knows to retry, not fail
// silently with an empty impact report.
func TestPreviewEdit_RejectsInvalidJSON(t *testing.T) {
	srv, _, _, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")
	res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
		"workspace_edit": "{not valid json}",
	})
	require.True(t, res.IsError, "preview_edit must reject malformed JSON")
	require.Contains(t, toolText(res), "invalid workspace_edit")
}

// TestPreviewEdit_RejectsEmptyEdit: a WorkspaceEdit with no document
// changes is a no-op; treating it as a success would mislead the
// caller into thinking they had a meaningful preview.
func TestPreviewEdit_RejectsEmptyEdit(t *testing.T) {
	srv, _, _, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")
	res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
		"workspace_edit": `{"changes":{}}`,
	})
	require.True(t, res.IsError, "preview_edit must reject empty workspace_edit")
	require.Contains(t, toolText(res), "no document changes")
}

// TestPreviewEdit_RenameSymbol simulates renaming a function with a
// distinctive signature so the rename heuristic can confidently pair
// the missing-base node with the added-overlay node. We can't use
// the harness's Target() because its parameterless void signature is
// the trivial shape that the heuristic deliberately rejects (every
// `func ()` would otherwise pair with every other `func ()`). The
// test seeds a fresh helper file with a distinctive signature and
// then renames it.
func TestPreviewEdit_RenameSymbol(t *testing.T) {
	srv, dir, _, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")

	// Push a helper file via overlay so the base graph picks up
	// `Encode(payload []byte) []byte`. Then simulate renaming it to
	// `Marshal(payload []byte) []byte` — same signature shape, new
	// name. The heuristic should classify this as a rename.
	helperPath := filepath.Join(dir, "encoder.go")
	require.NoError(t, srv.OverlayManager().RegisterWithID("seed", ""))
	require.NoError(t, srv.OverlayManager().Push("seed", daemon.OverlayFile{
		Path:    helperPath,
		Content: "package main\n\nfunc Encode(payload []byte) []byte { return payload }\n",
	}, nil))
	// Commit the seeded file to disk so it shows up in base on
	// reindex.
	require.NoError(t, writeSeedFile(helperPath,
		"package main\n\nfunc Encode(payload []byte) []byte { return payload }\n"))
	srv.OverlayManager().Drop("seed")
	_, err := srv.indexer.IncrementalReindex(dir)
	require.NoError(t, err)
	srv.indexer.ResolveAll()

	newContent := "package main\n\nfunc Marshal(payload []byte) []byte { return payload }\n"
	res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
		"workspace_edit": buildSingleFileEdit(helperPath, newContent),
		"diagnostics":    false, // no LSP setup in test harness
	})
	require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))

	body := toolText(res)
	require.Contains(t, body, "symbols_renamed", "rename surface must be present")
	require.Contains(t, body, "encoder.go::Marshal", "renamed-to symbol must surface")
	require.Contains(t, body, "encoder.go::Encode", "renamed-from symbol must surface")
}

// writeSeedFile is a thin os.WriteFile wrapper kept local to this
// test file so the test imports don't grow.
func writeSeedFile(path, content string) error {
	return os.WriteFile(path, []byte(content), 0o644)
}

// TestPreviewEdit_RemoveSymbol simulates a true removal: Target() is
// gone and nothing else replaces it (rename heuristic can't match).
// The broken_callers payload MUST include caller.go::Caller because
// the cross-file call edge can no longer resolve.
func TestPreviewEdit_RemoveSymbol(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")

	// Replace target.go with an empty package — Target() simply
	// disappears and there's no rename candidate.
	newContent := "package main\n"
	res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
		"workspace_edit": buildSingleFileEdit(targetFile, newContent),
		"diagnostics":    false,
	})
	require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))

	body := toolText(res)
	require.Contains(t, body, "Caller", "caller.go::Caller must show in broken_callers")
	require.Contains(t, body, "target removed or renamed")
}

// TestPreviewEdit_AddOnly: adding a brand-new function alongside
// Target() should produce a non-empty symbols_added list and no
// broken callers (Target stays put, the original caller resolves).
func TestPreviewEdit_AddOnly(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")

	newContent := "package main\n\nfunc Target() {}\n\nfunc NewHelper() {}\n"
	res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
		"workspace_edit": buildSingleFileEdit(targetFile, newContent),
		"diagnostics":    false,
	})
	require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))

	body := toolText(res)
	require.Contains(t, body, "NewHelper", "new symbol must surface in symbols_added")
	require.NotContains(t, body, "target removed or renamed",
		"the unchanged Target() must not surface as broken")
}

// TestPreviewEdit_OverlayPathsReported: the response must include the
// overlay_paths field so the caller knows exactly which paths the
// simulation touched (useful when the WorkspaceEdit spans multiple
// files and the agent needs a checklist).
func TestPreviewEdit_OverlayPathsReported(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")

	res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
		"workspace_edit": buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n\nfunc Added() {}\n"),
		"diagnostics":    false,
	})
	require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
	body := toolText(res)
	require.Contains(t, body, "overlay_paths")
	require.Contains(t, body, "touched_files")
}

// TestPreviewEdit_DoesNotMutateBase is the load-bearing isolation
// guarantee for the simulation engine — the same property the
// overlay layer enforces, but verified through the simulation tool
// surface to catch any future refactor that accidentally takes the
// promote-to-session path implicitly.
func TestPreviewEdit_DoesNotMutateBase(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	beforeIDs := baseNodeIDs(srv)

	ctx := WithSessionID(context.Background(), "test")
	res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
		"workspace_edit": buildSingleFileEdit(targetFile, "package main\n\nfunc Renamed() {}\n"),
		"diagnostics":    false,
	})
	require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))

	afterIDs := baseNodeIDs(srv)
	require.Equal(t, beforeIDs, afterIDs,
		"base graph must be byte-identical before and after preview_edit")
}

// TestSimulateChain_AppliesStepsInOrder: a two-step chain that first
// adds Helper(), then removes Target() entirely. The response must
// include both step records — step 0 reports Helper as added, step 1
// reports the removal and the resulting broken-caller surface.
func TestSimulateChain_AppliesStepsInOrder(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")

	step1 := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n\nfunc Helper() {}\n")
	step2 := buildSingleFileEdit(targetFile, "package main\n\nfunc Helper() {}\n")
	steps := fmt.Sprintf(`[%s,%s]`, step1, step2)

	res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
		"steps":         steps,
		"diagnostics":   false,
		"stop_on_error": false,
	})
	require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))

	body := toolText(res)
	require.Contains(t, body, `"total_steps":2`, "chain must record two applied steps")
	require.Contains(t, body, `"applied_steps":2`)
	require.Contains(t, body, "Helper", "step 1 must surface the added Helper symbol")
	require.Contains(t, body, "target removed or renamed",
		"step 2 must report the broken caller after Target is removed")
}

// TestSimulateChain_RejectsEmpty: an empty chain is an obvious caller
// error; the response must say so explicitly so the agent doesn't
// chase a phantom no-op result.
func TestSimulateChain_RejectsEmpty(t *testing.T) {
	srv, _, _, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")
	res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
		"steps": "[]",
	})
	require.True(t, res.IsError)
	require.Contains(t, toolText(res), "steps array is empty")
}

// TestSimulateChain_RejectsNonArray: a JSON object passed where the
// schema demands an array must error with a helpful message rather
// than panic in the unmarshaller.
func TestSimulateChain_RejectsNonArray(t *testing.T) {
	srv, _, _, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")
	res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
		"steps": `{"changes":{}}`,
	})
	require.True(t, res.IsError)
	require.Contains(t, toolText(res), "JSON array")
}

// TestSimulateChain_KeepPromotesOverlay verifies the `keep: true`
// promotion path: after a successful chain, the resulting state is
// pushed into a real overlay session bound to the caller's MCP
// session ID. A follow-up overlay_list must report the simulated file.
func TestSimulateChain_KeepPromotesOverlay(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	sessID := "sim-keep"
	require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
	ctx := WithSessionID(context.Background(), sessID)

	step := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n\nfunc Kept() {}\n")
	steps := fmt.Sprintf(`[%s]`, step)
	res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
		"steps":       steps,
		"keep":        true,
		"diagnostics": false,
	})
	require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))

	body := toolText(res)
	require.Contains(t, body, `"kept":true`)
	require.Contains(t, body, `"overlay_session_id":"`+sessID+`"`)

	// And the overlay manager itself must now hold the file.
	files, err := srv.OverlayManager().Files(sessID)
	require.NoError(t, err)
	require.NotEmpty(t, files, "keep must persist the simulated overlay")

	// A subsequent get_file_summary on the touched file must reflect
	// the promoted overlay state.
	listRes := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
		"path": filepath.Base(targetFile),
	})
	require.False(t, listRes.IsError)
	require.Contains(t, toolText(listRes), "Kept",
		"after keep:true the overlay must surface the simulated symbol")
}

// TestSimulateChain_InheritOverlay: when a session already has an
// overlay pushed, simulate_chain with inherit_overlay:true must
// layer on top of that buffer state — not on top of base.
func TestSimulateChain_InheritOverlay(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	sessID := "sim-inherit"
	require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
	// Editor has an unsaved buffer that adds Helper().
	require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
		Path:    targetFile,
		Content: "package main\n\nfunc Target() {}\n\nfunc Helper() {}\n",
	}, nil))

	ctx := WithSessionID(context.Background(), sessID)
	// The simulation adds a third function on top of the inherited
	// buffer; the resulting state must contain Helper AND Helper2.
	step := buildSingleFileEdit(targetFile,
		"package main\n\nfunc Target() {}\n\nfunc Helper() {}\n\nfunc Helper2() {}\n")
	res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
		"steps":           fmt.Sprintf(`[%s]`, step),
		"inherit_overlay": true,
		"diagnostics":     false,
	})
	require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))

	body := toolText(res)
	require.Contains(t, body, "Helper2", "added symbol must be visible")
	require.Contains(t, body, `"inherit_overlay":true`)
}

// TestSimulateChain_StopsOnError: when stop_on_error is true (default)
// the chain must abort the moment a step introduces a new ERROR-level
// diagnostic delta. Without an LSP wired we can't drive this directly,
// so we exercise the parameter plumbing only — the diagnostics path
// is covered separately when an LSP is available.
func TestSimulateChain_StopsOnErrorFlag(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "sim-stop")
	step := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n")
	res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
		"steps":         fmt.Sprintf(`[%s]`, step),
		"stop_on_error": true,
		"diagnostics":   false,
	})
	require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
	body := toolText(res)
	require.Contains(t, body, `"stopped_at":-1`,
		"with no errors stopped_at must be -1")
}

// TestSimulateChain_KeepRequiresOverlayManager: the keep flag is a
// no-op (with kept:false) when the calling session has no MCP
// session ID — embedded stdio without ctx-attached session. Verify
// we don't drop a stack trace in that case.
func TestSimulateChain_KeepWithoutSessionDegrades(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	step := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n")
	ctx := context.Background() // no session ID
	res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
		"steps":       fmt.Sprintf(`[%s]`, step),
		"keep":        true,
		"diagnostics": false,
	})
	require.False(t, res.IsError, "simulate_chain must not error when no MCP session: %s", toolText(res))
	require.Contains(t, toolText(res), `"kept":false`)
}

// TestPreviewEdit_CumulativeImpactReported confirms the response shape
// includes the impact rollup that the agent consumes to decide on
// test selection.
func TestPreviewEdit_CumulativeImpactReported(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)
	ctx := WithSessionID(context.Background(), "test")
	res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
		"workspace_edit": buildSingleFileEdit(targetFile, "package main\n\nfunc Renamed() {}\n"),
		"diagnostics":    false,
	})
	require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
	body := toolText(res)
	require.Contains(t, body, "impact", "impact rollup must be present")
	require.Contains(t, body, "summary", "summary line must be present")
}

// TestSimulateChain_ConcurrentSessions: two MCP sessions running
// simulate_chain in parallel against the same workspace must not
// step on each other's overlay state, mirroring the OverlayManager
// multi-tenant guarantee.
func TestSimulateChain_ConcurrentSessions(t *testing.T) {
	srv, _, targetFile, _ := setupOverlayServer(t)

	run := func(t *testing.T, name string, contentTag string, expect string) {
		t.Helper()
		ctx := WithSessionID(context.Background(), name)
		step := buildSingleFileEdit(targetFile, fmt.Sprintf("package main\n\nfunc Target() {}\n\nfunc %s() {}\n", contentTag))
		res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
			"steps":       fmt.Sprintf(`[%s]`, step),
			"diagnostics": false,
		})
		require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
		require.Contains(t, toolText(res), expect)
	}

	done := make(chan struct{}, 2)
	go func() {
		defer func() { done <- struct{}{} }()
		run(t, "sim-a", "AlphaSym", "AlphaSym")
	}()
	go func() {
		defer func() { done <- struct{}{} }()
		run(t, "sim-b", "BetaSym", "BetaSym")
	}()
	for range 2 {
		select {
		case <-done:
		case <-time.After(10 * time.Second):
			t.Fatalf("simulation goroutines did not finish in time")
		}
	}
}

// TestSimulateChain_NewFile exercises the new-file path: a
// WorkspaceEdit whose target file doesn't exist on disk should
// produce an overlay that emits the file's contents as a fresh
// buffer. The simulator treats the (0,0) range with newText as the
// initial content for an absent file.
func TestSimulateChain_NewFile(t *testing.T) {
	srv, dir, _, _ := setupOverlayServer(t)
	newPath := filepath.Join(dir, "fresh.go")
	step := buildSingleFileEdit(newPath, "package main\n\nfunc Fresh() {}\n")
	ctx := WithSessionID(context.Background(), "sim-new")
	res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
		"steps":       fmt.Sprintf(`[%s]`, step),
		"diagnostics": false,
	})
	require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
	body := toolText(res)
	require.True(t, strings.Contains(body, "Fresh") || strings.Contains(body, "fresh.go"),
		"new-file simulation must surface the new file: %s", body)
}
