package wire

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"

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

// TestReviewHandlersDoNotLeakPathBytesToOperatorLogs pins the path-leak
// audit codex6 + reviewer3 required for the ENTIRE Review chain — every
// slog call site across both the three legacy agent-review handlers AND the
// five remote-endpoint handlers. Filesystem paths carried by the wire
// messages (msg.Path, msg.TargetPath, remoteReview*.TargetPath/Path) MUST
// NOT surface anywhere in slog output produced while the handler runs.
// Path bytes are user data; an operator who tails the daemon log must not
// see another user's worktree location or file path.
//
// The test installs a capturing slog handler, dispatches each Review handler
// with a payload that embeds a unique sentinel substring in BOTH Path and
// TargetPath, then asserts the sentinel does not appear in any captured log
// record's message OR formatted attribute output.
//
// Audit surface — every slog call site in the Review chain MUST be
// exercised so the audit is mutation-testable for each one:
//
//   - Legacy "received" Info  (×3 handlers) — fires unconditionally on
//     handler entry. Driven by the synthetic legacy block; the marker is
//     in msg.TargetPath/Path. A future mutation that adds msg.TargetPath
//     or msg.Path as an attr here is caught.
//
//   - Legacy "failed" Warn    (×3 handlers) — fires only when
//     workspace.Review* returns an error. The synthetic block uses a
//     nonexistent target path which produces a not_git STATUS (not an
//     error), so the Warn never fires there. The error-path block below
//     uses a real git fixture with a control-char committed file to
//     trigger workspace.ErrReviewControlCharPath and exercise the Warn.
//
//   - Remote "handler error" Warn (×5 handlers) — fires only when
//     workspace.Review* returns an error. Same fixture drives all five
//     remote handlers through their Warn call site.
//
// Remote handlers do NOT emit a "received" Info on the happy path, but the
// synthetic remote block still drives all five through the capture pipeline
// so any future mutation that adds a success-path slog call (e.g., a
// "received" Info mirroring the legacy chain) is caught.
//
// The original audit (codex6) found exactly one offender before this test
// was written: handleReviewDiff's "received" slog.Info included "path",
// msg.Path. That attr has been removed; this test pins the fix and guards
// against any future regression that re-introduces raw path bytes across
// either chain.
func TestReviewHandlersDoNotLeakPathBytesToOperatorLogs(t *testing.T) {
	// Sentinel embedded in BOTH msg.Path and msg.TargetPath. The markers are
	// deliberately distinctive so a half-match (substring of a normalized
	// path) cannot accidentally slip through.
	const pathMarker = "SECRET_USER_PATH_MARKER_7QXR"
	const targetMarker = "SECRET_USER_TARGET_MARKER_9KBM"

	// capturingHandler records every formatted log record (level + message +
	// attrs resolved through AppendAttr) so the test can scan the full
	// rendered output for the markers.
	var captured []capturedRecord
	capture := &bytes.Buffer{}
	handler := slog.NewTextHandler(capture, &slog.HandlerOptions{Level: slog.LevelDebug})
	// Wrap so we ALSO get a structured copy of each record for assertions.
	wrapped := capturingHandler{inner: handler, captured: &captured}

	prev := slog.Default()
	slog.SetDefault(slog.New(wrapped))
	t.Cleanup(func() { slog.SetDefault(prev) })

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

	// Sentinel-bearing payloads for the synthetic blocks. The target path
	// points at a nonexistent location so legacy handlers' workspace.Review*
	// returns the not_git status (NOT an error); this exercises the
	// "received" Info path without firing the "failed" Warn. The Warn audit
	// lives in the error-path block further down, where a real git fixture
	// is used to trigger an actual workspace error.
	markerPath := "/nonexistent/" + pathMarker + "/file.txt"
	markerTarget := "/nonexistent/" + targetMarker

	// Drain any result messages so the send buffer never fills (which would
	// exercise the drop path and produce unrelated noise in metrics, though
	// not in slog).
	drainResults := func(t *testing.T, n int) {
		t.Helper()
		for i := 0; i < n; i++ {
			select {
			case <-c.sendCh:
			case <-time.After(2 * time.Second):
				t.Fatalf("handler %d did not produce a result within 2s", i)
			}
		}
	}

	// --- Legacy chain: summary / files / diff (synthetic, happy-path) ---
	// Each legacy handler emits exactly one "received" Info on entry and
	// produces exactly one send-channel result. Drain one per call.
	legacyRecordsBefore := len(captured)
	t.Run("legacy/summary", func(t *testing.T) {
		c.handleReviewSummary(ctx, agentReviewSummaryMsg{
			Type:       "agent:review:summary",
			AgentID:    agentID,
			RequestID:  "rq_legacy_summary",
			TargetPath: markerTarget,
		})
		drainResults(t, 1)
	})
	t.Run("legacy/files", func(t *testing.T) {
		c.handleReviewFiles(ctx, agentReviewFilesMsg{
			Type:       "agent:review:files",
			AgentID:    agentID,
			RequestID:  "rq_legacy_files",
			Scope:      "working",
			TargetPath: markerTarget,
		})
		drainResults(t, 1)
	})
	t.Run("legacy/diff", func(t *testing.T) {
		c.handleReviewDiff(ctx, agentReviewDiffMsg{
			Type:       "agent:review:diff",
			AgentID:    agentID,
			RequestID:  "rq_legacy_diff",
			Scope:      "working",
			Path:       markerPath,
			TargetPath: markerTarget,
		})
		drainResults(t, 1)
	})
	// Sanity check: the legacy block MUST have produced at least three
	// "received" Info records. If it did not, the capture handler is broken
	// and the marker assertions below would trivially pass — masking any
	// real leak.
	require.GreaterOrEqual(t, len(captured)-legacyRecordsBefore, 3,
		"legacy chain MUST emit at least one 'received' Info per handler — capture is broken; rendered logs:\n%s", capture.String())

	// --- Remote chain: summary / snapshot / fingerprint / files / diff ---
	// Remote handlers return (payload, outcome, error) directly — they do NOT
	// produce a send-channel message. On the happy path they emit ZERO slog
	// records (no "received" Info). Driving them through the capture pipeline
	// is still valuable: a future mutation that adds a success-path slog
	// call (e.g., a "received" Info mirroring the legacy chain) is caught
	// here. Protocol is unused by these handlers (signature: `_ *Protocol`),
	// so nil is safe and avoids coupling this audit to Protocol state.
	remoteAssignment := func(t *testing.T, endpointID string, payload any) remoteRequestAssignmentMsg {
		t.Helper()
		raw, err := json.Marshal(payload)
		require.NoError(t, err)
		return remoteRequestAssignmentMsg{
			Type:            "remote_request:assign",
			RequestID:       "rq_remote_" + endpointID,
			RemoteRequestID: "rrq_remote_" + endpointID,
			EndpointID:      endpointID,
			EndpointVersion: 1,
			Payload:         raw,
		}
	}

	// Shared payload shape for summary/snapshot/fingerprint (all use
	// remoteReviewSummaryPayload).
	summaryShape := func() remoteReviewSummaryPayload {
		return remoteReviewSummaryPayload{
			AgentID:    agentID,
			TargetPath: markerTarget,
			BaseRef:    "main",
			HeadRef:    "feature",
		}
	}

	t.Run("remote/summary", func(t *testing.T) {
		_, _, _ = handleRemoteReviewSummary(ctx, nil, remoteAssignment(t, "review.summary", summaryShape()))
	})
	t.Run("remote/snapshot", func(t *testing.T) {
		_, _, _ = handleRemoteReviewSnapshot(ctx, nil, remoteAssignment(t, "review.snapshot", summaryShape()))
	})
	t.Run("remote/fingerprint", func(t *testing.T) {
		_, _, _ = handleRemoteReviewFingerprint(ctx, nil, remoteAssignment(t, "review.fingerprint", summaryShape()))
	})
	t.Run("remote/files", func(t *testing.T) {
		_, _, _ = handleRemoteReviewFiles(ctx, nil, remoteAssignment(t, "review.files", remoteReviewFilesPayload{
			AgentID:    agentID,
			TargetPath: markerTarget,
			Scope:      "branch",
			BaseRef:    "main",
			HeadRef:    "feature",
		}))
	})
	t.Run("remote/diff", func(t *testing.T) {
		_, _, _ = handleRemoteReviewDiff(ctx, nil, remoteAssignment(t, "review.diff", remoteReviewDiffPayload{
			AgentID:    agentID,
			TargetPath: markerTarget,
			Scope:      "branch",
			Path:       markerPath,
			BaseRef:    "main",
			HeadRef:    "feature",
		}))
	})

	// --- Error-path audit: real fixture triggers workspace errors, firing
	// the "failed"/"handler error" Warn call sites that the synthetic blocks
	// above cannot reach. Without this block the audit does not exercise
	// those Warn calls at all, so a mutation that adds path bytes to them
	// would slip through.
	//
	// The fixture is a real git repo with a control-char committed file; see
	// internal/workspace/review_control_char_integration_test.go for the
	// upstream half of this contract. workspace.Review* returns
	// workspace.ErrReviewControlCharPath across every entry point, so each
	// handler fires its Warn exactly once. The targetMarker is embedded in
	// the fixture path itself so a Warn that accidentally logs TargetPath
	// (e.g., `slog.Warn(..., "target_path", payload.TargetPath)`) leaks the
	// marker and the test fails. Skipped on Windows where
	// newline-in-filename fixtures are unreliable, and when git is not
	// installed.
	if err := setupReviewErrorPathFixture(t, c, ctx, agentID, pathMarker, targetMarker, markerPath, drainResults); err != nil {
		t.Logf("skipping error-path audit: %v", err)
	}

	// Assert neither marker appears in the rendered text output NOR in the
	// structured per-record attrs captured by the wrapping handler.
	rendered := capture.String()
	require.NotEmpty(t, rendered,
		"slog handler captured no records — the test setup is broken; the legacy chain MUST emit at least the 'received' Info logs")
	require.NotContains(t, rendered, pathMarker,
		"PATH LEAK: raw path bytes acked in slog output. The Review chain (legacy + remote) MUST NOT log raw filesystem paths. Rendered logs:\n%s", rendered)
	require.NotContains(t, rendered, targetMarker,
		"TARGETPATH LEAK: raw target-path bytes acked in slog output. The Review chain (legacy + remote) MUST NOT log raw filesystem paths. Rendered logs:\n%s", rendered)

	for i, r := range captured {
		require.False(t, strings.Contains(r.message, pathMarker),
			"PATH LEAK: marker found in slog record %d message: %q", i, r.message)
		require.False(t, strings.Contains(r.attrs, pathMarker),
			"PATH LEAK: marker found in slog record %d attrs: %q", i, r.attrs)
		require.False(t, strings.Contains(r.message, targetMarker),
			"TARGETPATH LEAK: marker found in slog record %d message: %q", i, r.message)
		require.False(t, strings.Contains(r.attrs, targetMarker),
			"TARGETPATH LEAK: marker found in slog record %d attrs: %q", i, r.attrs)
	}
}

// setupReviewErrorPathFixture builds a real git repo whose HEAD tree contains
// a control-char-named file, then drives every legacy and remote Review
// handler against it so each handler's "failed"/"handler error" Warn fires
// at least once. Returns nil on success; returns a non-nil error (and skips
// sub-tests) when the runtime cannot build the fixture (no git binary,
// Windows).
//
// The fixture dir embeds the target marker so a Warn attr that accidentally
// logs TargetPath surfaces it. The diff handlers additionally receive the
// path marker in msg.Path / payload.Path so a Warn attr that logs Path
// surfaces it too.
func setupReviewErrorPathFixture(t *testing.T, c *Protocol, ctx context.Context, agentID, pathMarker, targetMarker, markerPath string, drain func(*testing.T, int)) error {
	t.Helper()
	if runtime.GOOS == "windows" {
		return errors.New("newline-in-filename fixtures are unreliable on Windows")
	}
	if _, err := exec.LookPath("git"); err != nil {
		return errors.New("git not available")
	}

	parent := t.TempDir()
	// Embed targetMarker in the fixture path itself so any leak of
	// TargetPath through a Warn attr is detectable.
	fixtureDir := filepath.Join(parent, targetMarker, "repo")
	require.NoError(t, os.MkdirAll(fixtureDir, 0o755))
	setupRemoteReviewTestRepo(t, fixtureDir)

	const offendingFile = "line\nbreak.txt"
	require.NoError(t, os.WriteFile(filepath.Join(fixtureDir, offendingFile), []byte("content\n"), 0o644))
	runGitCommand(t, fixtureDir, "add", offendingFile)
	runGitCommand(t, fixtureDir, "commit", "-m", "add control-char path")

	// Each legacy handler's "failed" Warn fires once; drain one send-channel
	// result per call.
	t.Run("error_path/legacy/summary", func(t *testing.T) {
		c.handleReviewSummary(ctx, agentReviewSummaryMsg{
			Type:       "agent:review:summary",
			AgentID:    agentID,
			RequestID:  "rq_err_legacy_summary",
			TargetPath: fixtureDir,
		})
		drain(t, 1)
	})
	t.Run("error_path/legacy/files", func(t *testing.T) {
		c.handleReviewFiles(ctx, agentReviewFilesMsg{
			Type:       "agent:review:files",
			AgentID:    agentID,
			RequestID:  "rq_err_legacy_files",
			Scope:      "branch",
			TargetPath: fixtureDir,
		})
		drain(t, 1)
	})
	t.Run("error_path/legacy/diff", func(t *testing.T) {
		c.handleReviewDiff(ctx, agentReviewDiffMsg{
			Type:       "agent:review:diff",
			AgentID:    agentID,
			RequestID:  "rq_err_legacy_diff",
			Scope:      "branch",
			Path:       markerPath,
			TargetPath: fixtureDir,
		})
		drain(t, 1)
	})

	// Remote error-path. Each handler's "handler error" Warn fires once. No
	// send-channel result to drain (the signature returns directly).
	remoteAssignment := func(t *testing.T, endpointID string, payload any) remoteRequestAssignmentMsg {
		t.Helper()
		raw, err := json.Marshal(payload)
		require.NoError(t, err)
		return remoteRequestAssignmentMsg{
			Type:            "remote_request:assign",
			RequestID:       "rq_err_remote_" + endpointID,
			RemoteRequestID: "rrq_err_remote_" + endpointID,
			EndpointID:      endpointID,
			EndpointVersion: 1,
			Payload:         raw,
		}
	}
	summaryShape := func() remoteReviewSummaryPayload {
		return remoteReviewSummaryPayload{
			AgentID:    agentID,
			TargetPath: fixtureDir,
			BaseRef:    "",
			HeadRef:    "",
		}
	}
	t.Run("error_path/remote/summary", func(t *testing.T) {
		_, _, _ = handleRemoteReviewSummary(ctx, nil, remoteAssignment(t, "review.summary", summaryShape()))
	})
	t.Run("error_path/remote/snapshot", func(t *testing.T) {
		_, _, _ = handleRemoteReviewSnapshot(ctx, nil, remoteAssignment(t, "review.snapshot", summaryShape()))
	})
	t.Run("error_path/remote/fingerprint", func(t *testing.T) {
		_, _, _ = handleRemoteReviewFingerprint(ctx, nil, remoteAssignment(t, "review.fingerprint", summaryShape()))
	})
	t.Run("error_path/remote/files", func(t *testing.T) {
		_, _, _ = handleRemoteReviewFiles(ctx, nil, remoteAssignment(t, "review.files", remoteReviewFilesPayload{
			AgentID:    agentID,
			TargetPath: fixtureDir,
			Scope:      "branch",
		}))
	})
	t.Run("error_path/remote/diff", func(t *testing.T) {
		_, _, _ = handleRemoteReviewDiff(ctx, nil, remoteAssignment(t, "review.diff", remoteReviewDiffPayload{
			AgentID:    agentID,
			TargetPath: fixtureDir,
			Scope:      "branch",
			Path:       markerPath,
		}))
	})
	return nil
}

// capturingHandler wraps an inner slog.Handler and records every record
// passed through it. The test uses it so it can scan BOTH the rendered
// text output (via the inner TextHandler writing to capture) AND the
// structured attrs (via the recorded records). Implementing HandleAttr
// recursively is necessary because attrs can be nested via groups.
type capturedRecord struct {
	level   slog.Level
	message string
	attrs   string
}

type capturingHandler struct {
	inner    slog.Handler
	captured *[]capturedRecord
}

func (h capturingHandler) Enabled(_ context.Context, lvl slog.Level) bool {
	return h.inner.Enabled(context.Background(), lvl)
}

func (h capturingHandler) Handle(ctx context.Context, r slog.Record) error {
	var attrBuf bytes.Buffer
	r.Attrs(func(a slog.Attr) bool {
		attrBuf.WriteString(a.Key)
		attrBuf.WriteString("=")
		attrBuf.WriteString(a.Value.String())
		attrBuf.WriteString(" ")
		return true
	})
	*h.captured = append(*h.captured, capturedRecord{
		level:   r.Level,
		message: r.Message,
		attrs:   attrBuf.String(),
	})
	return h.inner.Handle(ctx, r.Clone())
}

func (h capturingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return capturingHandler{inner: h.inner.WithAttrs(attrs), captured: h.captured}
}

func (h capturingHandler) WithGroup(name string) slog.Handler {
	return capturingHandler{inner: h.inner.WithGroup(name), captured: h.captured}
}
