package wire

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

	"github.com/stretchr/testify/require"
)

// TestRemoteReviewEndpointsMapControlCharOutcomeOnWire is the Desktop
// transport-adapter half of the wire-survival contract reviewer3 required:
// when the workspace layer raises workspace.ErrReviewControlCharPath because
// the repo's tree contains a path with embedded control characters, all
// Review remote-endpoint handlers MUST emit the stable outcome string
// "review_target_unsupported" on the wire (in the Outcome field of
// remoteRequestResultMsg). The Hub-side mapper reconstructs a typed sentinel
// from that string; without it, every workspace error would collapse to
// "endpoint_error" and the HTTP layer could only return a uniform 500.
//
// The test builds a real git repo with a newline-named file (Unix-legal,
// but go-git's merkletrie validator rejects it), drives each of the five
// endpoints through the production handleRemoteRequestAssignment entrypoint,
// and asserts the wire result carries the stable outcome. It also asserts
// the offending filename does NOT appear anywhere in the result payload:
// the workspace sentinel's Error() text is path-free, and any leak here
// would defeat the path-leak protection the wrapReviewTreeError layer
// guarantees.
//
// Skipped on Windows: newline-in-filename is technically legal on NTFS but
// many tools (and the Go os package on windows) handle it inconsistently;
// the test would be flaky. The wire contract itself applies on every
// platform — the assertion is that Desktop emits the right outcome, not
// that any specific platform accepts the fixture.
func TestRemoteReviewEndpointsMapControlCharOutcomeOnWire(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("newline-in-filename fixtures are unreliable on Windows")
	}
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	// Offending path: a filename with an embedded newline. The literal
	// bytes are kept short and distinctive so the leak-detector below can
	// pin a single substring ("line\nbreak") without false positives from
	// unrelated test scaffolding.
	const offendingFile = "line\nbreak.txt"
	const leakMarker = "line\nbreak"

	repoDir := t.TempDir()
	setupRemoteReviewTestRepo(t, repoDir)
	// Add the offending file and commit. Git accepts \n in filenames on
	// Unix; the merkletrie validator rejects them only at diff time.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, offendingFile), []byte("content\n"), 0o644))
	runGitCommand(t, repoDir, "add", offendingFile)
	runGitCommand(t, repoDir, "commit", "-m", "add control-char path")

	endpoints := []struct {
		name       string
		endpointID string
		payload    any
	}{
		{
			name:       "summary",
			endpointID: remoteEndpointReviewSummary,
			payload:    remoteReviewSummaryPayload{AgentID: "agent-cc", TargetPath: repoDir},
		},
		{
			name:       "snapshot",
			endpointID: remoteEndpointReviewSnapshot,
			payload:    remoteReviewSummaryPayload{AgentID: "agent-cc", TargetPath: repoDir},
		},
		{
			name:       "fingerprint",
			endpointID: remoteEndpointReviewFingerprint,
			payload:    remoteReviewSummaryPayload{AgentID: "agent-cc", TargetPath: repoDir},
		},
		{
			name:       "files",
			endpointID: remoteEndpointReviewFiles,
			payload:    remoteReviewFilesPayload{AgentID: "agent-cc", TargetPath: repoDir, Scope: "branch"},
		},
		{
			name:       "diff",
			endpointID: remoteEndpointReviewDiff,
			payload:    remoteReviewDiffPayload{AgentID: "agent-cc", TargetPath: repoDir, Scope: "branch", Path: offendingFile},
		},
		{
			name:       "diff_batch",
			endpointID: remoteEndpointReviewDiffBatch,
			payload:    remoteReviewDiffBatchPayload{AgentID: "agent-cc", TargetPath: repoDir, Scope: "branch", Paths: []string{"tracked.txt", offendingFile}},
		},
	}

	for _, ep := range endpoints {
		t.Run(ep.name, func(t *testing.T) {
			ch := &Protocol{
				id:                     "agentteam-test-cc",
				sendCh:                 make(chan []byte, sendBufSize),
				remoteEndpointRegistry: defaultDesktopRemoteEndpointRegistry(),
			}

			ch.handleRemoteRequestAssignment(context.Background(), nil, mustJSON(t, remoteRequestAssignmentMsg{
				Type:                 remoteRequestAssignType,
				SchemaVersion:        1,
				RequestID:            "rrq_cc_" + ep.name,
				RemoteRequestID:      "rrq_cc_" + ep.name,
				EndpointID:           ep.endpointID,
				EndpointVersion:      1,
				PayloadSchemaVersion: 1,
				DeadlineAt:           time.Now().Add(5 * time.Second).UTC().Format(time.RFC3339),
				TimeoutBudgetMs:      5000,
				CancelToken:          "cancel_cc_" + ep.name,
				Limits:               remoteEndpointLimits{RequestBytes: 1024, ResponseBytes: 256 * 1024},
				Payload:              mustJSONRaw(t, ep.payload),
			}))

			result := nextRemoteRequestResult(t, ch)
			require.False(t, result.Success,
				"endpoint %s MUST fail when the repo tree contains a control-char path — a success result means the workspace layer did NOT raise ErrReviewControlCharPath and the whole wire contract is moot",
				ep.name)
			require.Equal(t, reviewTargetUnsupportedOutcome, result.Outcome,
				"endpoint %s MUST emit stable outcome %q on the wire when the workspace sentinel fires; got outcome=%q error=%q. Any other outcome would collapse to a generic 500 at the HTTP layer and defeat the product-limitation signal.",
				ep.name, reviewTargetUnsupportedOutcome, result.Outcome, result.Error)

			// Path-leak protection: neither the Outcome field, the Error
			// field, nor the marshaled result envelope may contain the
			// offending filename bytes. The workspace sentinel's Error()
			// is path-free; any leak would have to come from a regression
			// that re-included the original go-git error message.
			//
			// We scan the full marshaled JSON envelope (not just result.Error)
			// because future regressions might attach the path elsewhere
			// (e.g. a debug field, a wrapped error chain).
			encoded, err := json.Marshal(result)
			require.NoError(t, err)
			require.False(t, strings.Contains(string(encoded), leakMarker),
				"endpoint %s leaked the offending path bytes %q into the wire result envelope: %s",
				ep.name, leakMarker, string(encoded))
			require.False(t, strings.Contains(result.Outcome, leakMarker),
				"endpoint %s outcome field leaked path bytes: %q", ep.name, result.Outcome)
			require.False(t, strings.Contains(result.Error, leakMarker),
				"endpoint %s error field leaked path bytes: %q", ep.name, result.Error)
		})
	}
}

// TestLegacyReviewHandlersMapControlCharErrorCodeOnWire is the Desktop
// legacy-handler half of the same wire-survival contract. The legacy
// agentReview*ResultMsg structs carry no Outcome field — they have only a
// free-form Error string. The contract extension is a new ErrorCode field
// that carries the same stable signal. When the workspace sentinel fires,
// handleReview{Summary,Files,Diff} MUST populate ErrorCode with
// reviewTargetUnsupportedLegacyCode so the Hub-side legacy handler can
// reconstruct the typed sentinel.
//
// Without this, the legacy path would collapse every workspace error to a
// plain errors.New(err.Error()) at the Hub boundary and the HTTP layer
// could only return a uniform 500 — the product-limitation signal would
// never reach the frontend through the legacy RPCs.
func TestLegacyReviewHandlersMapControlCharErrorCodeOnWire(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("newline-in-filename fixtures are unreliable on Windows")
	}
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	const offendingFile = "line\nbreak.txt"
	const leakMarker = "line\nbreak"

	repoDir := t.TempDir()
	setupRemoteReviewTestRepo(t, repoDir)
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, offendingFile), []byte("content\n"), 0o644))
	runGitCommand(t, repoDir, "add", offendingFile)
	runGitCommand(t, repoDir, "commit", "-m", "add control-char path")

	c := &Protocol{
		id:     "ch_legacy_cc",
		sendCh: make(chan []byte, 8),
	}
	ctx := context.Background()
	const agentID = "agt_legacy_cc"

	readResult := func(t *testing.T, into any) {
		t.Helper()
		select {
		case raw := <-c.sendCh:
			require.NoError(t, json.Unmarshal(raw, into), "unmarshal handler result")
		case <-time.After(2 * time.Second):
			t.Fatal("legacy handler did not produce a result within 2s")
		}
	}

	t.Run("summary sets ErrorCode=review_target_unsupported", func(t *testing.T) {
		c.handleReviewSummary(ctx, agentReviewSummaryMsg{
			Type:       "agent:review:summary",
			AgentID:    agentID,
			RequestID:  "rq_summary_cc",
			TargetPath: repoDir,
		})
		var result agentReviewSummaryResultMsg
		readResult(t, &result)
		require.Equal(t, agentID, result.AgentID)
		require.Equal(t, reviewTargetUnsupportedLegacyCode, result.ErrorCode,
			"handleReviewSummary MUST populate ErrorCode when the workspace sentinel fires")
		require.False(t, strings.Contains(result.Error, leakMarker),
			"handleReviewSummary Error field leaked the offending path: %q", result.Error)
	})

	t.Run("files sets ErrorCode=review_target_unsupported", func(t *testing.T) {
		c.handleReviewFiles(ctx, agentReviewFilesMsg{
			Type:       "agent:review:files",
			AgentID:    agentID,
			RequestID:  "rq_files_cc",
			Scope:      "branch",
			TargetPath: repoDir,
		})
		var result agentReviewFilesResultMsg
		readResult(t, &result)
		require.Equal(t, agentID, result.AgentID)
		require.Equal(t, reviewTargetUnsupportedLegacyCode, result.ErrorCode,
			"handleReviewFiles MUST populate ErrorCode when the workspace sentinel fires")
		require.False(t, strings.Contains(result.Error, leakMarker),
			"handleReviewFiles Error field leaked the offending path: %q", result.Error)
	})

	t.Run("diff sets ErrorCode=review_target_unsupported", func(t *testing.T) {
		c.handleReviewDiff(ctx, agentReviewDiffMsg{
			Type:       "agent:review:diff",
			AgentID:    agentID,
			RequestID:  "rq_diff_cc",
			Scope:      "branch",
			Path:       offendingFile,
			TargetPath: repoDir,
		})
		var result agentReviewDiffResultMsg
		readResult(t, &result)
		require.Equal(t, agentID, result.AgentID)
		require.Equal(t, reviewTargetUnsupportedLegacyCode, result.ErrorCode,
			"handleReviewDiff MUST populate ErrorCode when the workspace sentinel fires")
		require.False(t, strings.Contains(result.Error, leakMarker),
			"handleReviewDiff Error field leaked the offending path: %q", result.Error)
	})
}
