package main

import (
	"bytes"
	"context"
	"encoding/json"
	"net"
	"strings"
	"testing"
	"time"

	pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
	"google.golang.org/grpc"
)

func TestRunVersionJSON(t *testing.T) {
	var stdout bytes.Buffer
	var stderr bytes.Buffer

	if err := runWithIO(t.Context(), []string{"version", "-json"}, &stdout, &stderr); err != nil {
		t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
	}

	var output versionOutput
	if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
		t.Fatalf("parse JSON: %v", err)
	}
	if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
		t.Fatalf("protocol versions were not populated: %+v", output)
	}
}

func TestCommonOptionsRedactsAPIKey(t *testing.T) {
	options, err := (&commonOptions{
		Endpoint:    "localhost:5000",
		APIKey:      "mxgw_super_secret",
		Plaintext:   true,
		CallTimeout: "2s",
	}).resolved()
	if err != nil {
		t.Fatalf("resolved() error = %v", err)
	}

	data, err := json.Marshal(options)
	if err != nil {
		t.Fatalf("marshal options: %v", err)
	}
	if strings.Contains(string(data), "super_secret") {
		t.Fatalf("redacted JSON leaked API key: %s", data)
	}
	if !strings.Contains(string(data), "mxgw") {
		t.Fatalf("redacted JSON did not preserve key shape: %s", data)
	}
}

func TestRunBatchEmitsEORAfterVersion(t *testing.T) {
	var stdout bytes.Buffer
	var stderr bytes.Buffer

	in := strings.NewReader("version --json\n")
	if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
		t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
	}

	out := stdout.String()
	if !strings.Contains(out, "\n"+batchEOR+"\n") && !strings.HasSuffix(out, batchEOR+"\n") {
		t.Fatalf("expected EOR marker %q in stdout; got: %q", batchEOR, out)
	}

	idx := strings.Index(out, batchEOR)
	if idx <= 0 {
		t.Fatalf("EOR marker not found or appeared before any output: %q", out)
	}
	payload := out[:idx]
	var output versionOutput
	if err := json.Unmarshal([]byte(payload), &output); err != nil {
		t.Fatalf("parse JSON block before EOR: %v (payload=%q)", err, payload)
	}
	if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
		t.Fatalf("protocol versions were not populated: %+v", output)
	}
}

func TestParseValueBuildsTypedValue(t *testing.T) {
	value, err := parseValue("int32", "123")
	if err != nil {
		t.Fatalf("parseValue() error = %v", err)
	}
	if got := value.GetInt32Value(); got != 123 {
		t.Fatalf("int32 value = %d, want 123", got)
	}
}

// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-022 fix:
// secured-only flags must be unavailable on non-secured variants, and
// vice-versa, so a wrong-variant flag fails with a clean "flag provided
// but not defined" error instead of silently no-op'ing.
func TestRunWriteBulkVariantGatesSecuredFlags(t *testing.T) {
	cases := []struct {
		name string
		args []string
	}{
		{
			name: "write-bulk-rejects-current-user-id",
			args: []string{"write-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
		},
		{
			name: "write-bulk-rejects-verifier-user-id",
			args: []string{"write-bulk", "-verifier-user-id", "5", "-item-handles", "1", "-values", "1"},
		},
		{
			name: "write2-bulk-rejects-current-user-id",
			args: []string{"write2-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
		},
		{
			name: "write-secured-bulk-rejects-user-id",
			args: []string{"write-secured-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
		},
		{
			name: "write-secured2-bulk-rejects-user-id",
			args: []string{"write-secured2-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			var stdout, stderr bytes.Buffer
			err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
			if err == nil {
				t.Fatalf("runWithIO(%v) returned no error", tc.args)
			}
			if !strings.Contains(err.Error(), "flag provided but not defined") {
				t.Fatalf("runWithIO(%v) error = %v; want 'flag provided but not defined'", tc.args, err)
			}
		})
	}
}

// TestRunBenchReadBulkRespectsContextCancellation pins the Client.Go-023
// fix: the warm-up and steady-state wall-clock loops must honour ctx.Err()
// so an external cancel (Ctrl+C, parent-cancel from a cross-language bench
// driver) short-circuits the bench instead of spinning failing ReadBulk
// calls until the wall-clock deadline elapses.
func TestRunBenchReadBulkRespectsContextCancellation(t *testing.T) {
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	server := grpc.NewServer()
	fake := &benchFakeGateway{}
	pb.RegisterMxAccessGatewayServer(server, fake)
	go func() {
		_ = server.Serve(listener)
	}()
	defer server.Stop()
	defer listener.Close()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Long warm-up + duration, so if the ctx.Err() guard were missing the
	// loops would run for ~10s. With the guard, the cancel below short-
	// circuits both loops within ~one ReadBulk iteration.
	args := []string{
		"bench-read-bulk",
		"-endpoint", listener.Addr().String(),
		"-plaintext",
		"-api-key", "test",
		"-warmup-seconds", "5",
		"-duration-seconds", "5",
		"-bulk-size", "1",
		"-timeout-ms", "100",
	}

	// Cancel after a brief delay — far less than warmup+duration (10s).
	go func() {
		time.Sleep(150 * time.Millisecond)
		cancel()
	}()

	var stdout, stderr bytes.Buffer
	start := time.Now()
	err = runWithIO(ctx, args, &stdout, &stderr)
	elapsed := time.Since(start)

	// With the ctx.Err() guard, the loops exit well before the wall-clock
	// deadlines (warmup=5s + duration=5s = 10s). Allow generous slack for
	// CI noise but assert clearly less than the un-guarded worst case.
	if elapsed > 4*time.Second {
		t.Fatalf("bench-read-bulk took %s after ctx cancel; want <4s (ctx.Err() guard missing?). err=%v stderr=%s", elapsed, err, stderr.String())
	}
}

// TestRunPingPlainText verifies the ping subcommand round-trips through the
// fake gateway and prints the echo (diagnostic_message) in plain-text mode.
func TestRunPingPlainText(t *testing.T) {
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	server := grpc.NewServer()
	fake := &pingFakeGateway{}
	pb.RegisterMxAccessGatewayServer(server, fake)
	go func() { _ = server.Serve(listener) }()
	defer server.Stop()
	defer listener.Close()

	var stdout, stderr bytes.Buffer
	args := []string{
		"ping",
		"-endpoint", listener.Addr().String(),
		"-plaintext",
		"-api-key", "test",
		"-session-id", "test-session",
		"-message", "hello",
	}
	if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
		t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
	}
	got := strings.TrimSpace(stdout.String())
	if got != "pong:hello" {
		t.Fatalf("ping plain-text output = %q, want %q", got, "pong:hello")
	}
}

// TestRunPingJSON verifies the ping subcommand emits valid JSON in --json mode.
func TestRunPingJSON(t *testing.T) {
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	server := grpc.NewServer()
	fake := &pingFakeGateway{}
	pb.RegisterMxAccessGatewayServer(server, fake)
	go func() { _ = server.Serve(listener) }()
	defer server.Stop()
	defer listener.Close()

	var stdout, stderr bytes.Buffer
	args := []string{
		"ping",
		"-endpoint", listener.Addr().String(),
		"-plaintext",
		"-api-key", "test",
		"-session-id", "test-session",
		"-message", "hello",
		"-json",
	}
	if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
		t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
	}
	var out commandReplyOutput
	if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
		t.Fatalf("parse JSON: %v\noutput: %s", err, stdout.String())
	}
	if out.Command != "ping" {
		t.Fatalf("command = %q, want %q", out.Command, "ping")
	}
	// The fake gateway echoes "pong:<message>" in diagnostic_message; verify the
	// echo appears in the serialised reply so a future regression that wired
	// PingRaw to the wrong proto field would be caught here.
	replyStr := string(out.Reply)
	if !strings.Contains(replyStr, "pong:hello") {
		t.Fatalf("ping JSON reply missing echoed message %q; reply = %s", "pong:hello", replyStr)
	}
}

// TestRunPingRequiresSessionID verifies the ping subcommand rejects missing session-id.
func TestRunPingRequiresSessionID(t *testing.T) {
	var stdout, stderr bytes.Buffer
	err := runWithIO(t.Context(), []string{"ping", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
	if err == nil {
		t.Fatalf("runWithIO(ping without --session-id) returned no error")
	}
	if !strings.Contains(err.Error(), "session-id is required") {
		t.Fatalf("error = %v; want 'session-id is required'", err)
	}
}

// pingFakeGateway handles Invoke for MX_COMMAND_KIND_PING by echoing the
// message back in the diagnostic_message field so the CLI plain-text path
// has a deterministic, non-empty string to assert on.
type pingFakeGateway struct {
	pb.UnimplementedMxAccessGatewayServer
}

func (g *pingFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
	echo := "pong:" + req.GetCommand().GetPing().GetMessage()
	return &pb.MxCommandReply{
		SessionId:         req.GetSessionId(),
		Kind:              pb.MxCommandKind_MX_COMMAND_KIND_PING,
		DiagnosticMessage: echo,
		ProtocolStatus:    &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
	}, nil
}

// benchFakeGateway is a minimal MxAccessGatewayServer that satisfies the
// bench-read-bulk session-setup sequence (OpenSession + Invoke for Register
// / SubscribeBulk / ReadBulk / UnsubscribeBulk / CloseSession).
type benchFakeGateway struct {
	pb.UnimplementedMxAccessGatewayServer
}

func (g *benchFakeGateway) OpenSession(_ context.Context, _ *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
	return &pb.OpenSessionReply{
		SessionId:      "bench-session",
		ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
	}, nil
}

func (g *benchFakeGateway) CloseSession(_ context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
	return &pb.CloseSessionReply{
		SessionId:      req.GetSessionId(),
		ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
	}, nil
}

func (g *benchFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
	kind := req.GetCommand().GetKind()
	reply := &pb.MxCommandReply{
		SessionId:      req.GetSessionId(),
		Kind:           kind,
		ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
	}
	switch kind {
	case pb.MxCommandKind_MX_COMMAND_KIND_REGISTER:
		reply.Payload = &pb.MxCommandReply_Register{Register: &pb.RegisterReply{ServerHandle: 1}}
	case pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK:
		reply.Payload = &pb.MxCommandReply_SubscribeBulk{SubscribeBulk: &pb.BulkSubscribeReply{
			Results: []*pb.SubscribeResult{{ServerHandle: 1, ItemHandle: 1, WasSuccessful: true}},
		}}
	case pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK:
		reply.Payload = &pb.MxCommandReply_ReadBulk{ReadBulk: &pb.BulkReadReply{
			Results: []*pb.BulkReadResult{{ItemHandle: 1, WasSuccessful: true, WasCached: true}},
		}}
	case pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK:
		reply.Payload = &pb.MxCommandReply_UnsubscribeBulk{UnsubscribeBulk: &pb.BulkSubscribeReply{}}
	}
	return reply, nil
}

// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the Client.Go-023-adjacent
// positivity checks so they cannot drift while resolving the cancellation finding.
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
	var stdout, stderr bytes.Buffer
	err := runWithIO(t.Context(), []string{"bench-read-bulk", "-bulk-size", "0"}, &stdout, &stderr)
	if err == nil || !strings.Contains(err.Error(), "bulk-size must be positive") {
		t.Fatalf("bench-read-bulk -bulk-size 0 error = %v", err)
	}
}

// browseFakeGalaxy implements BrowseChildren for the galaxy-browse subcommand
// tests. It returns two root objects when no parent is supplied (the first
// flagged as having children), and one child when the first root's gobject id
// is supplied as the parent. The recorded last request lets a test assert the
// CLI forwarded the parent and filter fields onto the wire.
type browseFakeGalaxy struct {
	pb.UnimplementedGalaxyRepositoryServer
	lastRequest *pb.BrowseChildrenRequest
}

func (g *browseFakeGalaxy) BrowseChildren(_ context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
	g.lastRequest = req
	if req.GetParentGobjectId() == 10 {
		return &pb.BrowseChildrenReply{
			Children: []*pb.GalaxyObject{
				{GobjectId: 11, TagName: "Area1.Tank", BrowseName: "Tank"},
			},
			ChildHasChildren: []bool{false},
		}, nil
	}
	return &pb.BrowseChildrenReply{
		Children: []*pb.GalaxyObject{
			{GobjectId: 10, TagName: "Area1", BrowseName: "Area1"},
			{GobjectId: 20, TagName: "Area2", BrowseName: "Area2"},
		},
		ChildHasChildren: []bool{true, false},
	}, nil
}

func startBrowseFakeGalaxy(t *testing.T) (addr string, fake *browseFakeGalaxy) {
	t.Helper()
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	server := grpc.NewServer()
	fake = &browseFakeGalaxy{}
	pb.RegisterGalaxyRepositoryServer(server, fake)
	go func() { _ = server.Serve(listener) }()
	t.Cleanup(func() {
		server.Stop()
		_ = listener.Close()
	})
	return listener.Addr().String(), fake
}

// TestRunGalaxyBrowseTextTree verifies the galaxy-browse subcommand issues
// BrowseChildren for the root walk, eagerly expands one level when --depth is
// set, and renders an indented tree.
func TestRunGalaxyBrowseTextTree(t *testing.T) {
	addr, _ := startBrowseFakeGalaxy(t)

	var stdout, stderr bytes.Buffer
	args := []string{
		"galaxy-browse",
		"-endpoint", addr,
		"-plaintext",
		"-api-key", "test",
		"-depth", "1",
	}
	if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
		t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
	}
	out := stdout.String()
	// Both roots present; the first root's eagerly-expanded child appears
	// indented beneath it.
	for _, want := range []string{"Area1", "Area2", "Tank"} {
		if !strings.Contains(out, want) {
			t.Fatalf("galaxy-browse text output missing %q; got:\n%s", want, out)
		}
	}
	if !strings.Contains(out, "  ") {
		t.Fatalf("galaxy-browse text output not indented for children; got:\n%s", out)
	}
}

// TestRunGalaxyBrowseJSON verifies the galaxy-browse subcommand emits valid
// nested JSON and forwards filter options onto the BrowseChildren request.
func TestRunGalaxyBrowseJSON(t *testing.T) {
	addr, fake := startBrowseFakeGalaxy(t)

	var stdout, stderr bytes.Buffer
	args := []string{
		"galaxy-browse",
		"-endpoint", addr,
		"-plaintext",
		"-api-key", "test",
		"-depth", "1",
		"-tag-name-glob", "Area%",
		"-alarm-bearing-only",
		"-json",
	}
	if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
		t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
	}

	var payload map[string]any
	if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
		t.Fatalf("parse JSON: %v\noutput: %s", err, stdout.String())
	}
	if payload["command"] != "galaxy-browse" {
		t.Fatalf("command = %v, want galaxy-browse", payload["command"])
	}
	nodes, ok := payload["nodes"].([]any)
	if !ok || len(nodes) != 2 {
		t.Fatalf("nodes = %v, want 2 root nodes", payload["nodes"])
	}
	// Filter fields must have reached the wire.
	if got := fake.lastRequest.GetTagNameGlob(); got != "Area%" {
		t.Fatalf("BrowseChildren TagNameGlob = %q, want %q", got, "Area%")
	}
	if !fake.lastRequest.GetAlarmBearingOnly() {
		t.Fatalf("BrowseChildren AlarmBearingOnly = false, want true")
	}
}

// TestRunGalaxyBrowseParentSingleLevel verifies that passing --parent fetches a
// single level of children for that parent via the parent-scoped request.
func TestRunGalaxyBrowseParentSingleLevel(t *testing.T) {
	addr, fake := startBrowseFakeGalaxy(t)

	var stdout, stderr bytes.Buffer
	args := []string{
		"galaxy-browse",
		"-endpoint", addr,
		"-plaintext",
		"-api-key", "test",
		"-parent", "10",
	}
	if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
		t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
	}
	if !strings.Contains(stdout.String(), "Tank") {
		t.Fatalf("galaxy-browse -parent output missing child %q; got:\n%s", "Tank", stdout.String())
	}
	if got := fake.lastRequest.GetParentGobjectId(); got != 10 {
		t.Fatalf("BrowseChildren ParentGobjectId = %d, want 10", got)
	}
}

// TestRunBatchSkipsBlankLinesAndContinuesUntilEOF pins the Client.Go-027 fix:
// a blank line in the middle of a batch session must NOT terminate the loop —
// only stdin EOF ends the session.
func TestRunBatchSkipsBlankLinesAndContinuesUntilEOF(t *testing.T) {
	var stdout, stderr bytes.Buffer

	// version -> blank -> version (a stray blank line in the middle of a
	// programmatic session).
	in := strings.NewReader("version --json\n\nversion --json\n")
	if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
		t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
	}

	out := stdout.String()
	// Both version commands must have produced a result before the EOR sentinel.
	if count := strings.Count(out, batchEOR); count != 2 {
		t.Fatalf("EOR sentinel count = %d, want 2 (one per command, blank line skipped); out = %q", count, out)
	}
}

// TestRunBatchHandlesLongCommandLine pins the Client.Go-026 fix: a command
// line longer than the default bufio.Scanner token size (64 KiB) must not
// abort the batch session.
func TestRunBatchHandlesLongCommandLine(t *testing.T) {
	var stdout, stderr bytes.Buffer

	// Build a single command line larger than 64 KiB. The command itself is
	// invalid (no real session) but runBatch must still emit an EOR sentinel
	// and continue to the next command rather than dropping the line on the
	// floor with a bufio.ErrTooLong from the outer return.
	huge := strings.Repeat("tag-with-a-reasonably-long-name-and-suffix,", 2000) + "trailing"
	line := "subscribe-bulk -session-id none -items " + huge
	if len(line) <= 64*1024 {
		t.Fatalf("test setup error: long line length = %d, want > 64KiB", len(line))
	}
	in := strings.NewReader(line + "\nversion --json\n")

	if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
		t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
	}

	out := stdout.String()
	// Both commands must produce an EOR sentinel — the long line should be a
	// per-command error (still emitted with EOR), then the version command
	// should run normally.
	if count := strings.Count(out, batchEOR); count != 2 {
		t.Fatalf("EOR sentinel count = %d, want 2 (one per command, even when first is too long); out length = %d", count, len(out))
	}
}

// TestRunBenchReadBulkRejectsNonPositiveDuration pins the -duration-seconds
// positivity guard so the bench window cannot be configured to zero/negative.
func TestRunBenchReadBulkRejectsNonPositiveDuration(t *testing.T) {
	var stdout, stderr bytes.Buffer
	err := runWithIO(t.Context(), []string{"bench-read-bulk", "-duration-seconds", "0"}, &stdout, &stderr)
	if err == nil || !strings.Contains(err.Error(), "duration-seconds must be positive") {
		t.Fatalf("bench-read-bulk -duration-seconds 0 error = %v", err)
	}
}

// TestRunStreamEventsRequiresSessionID pins the session-id guard so stream-events
// fails fast before dialing when no session id is supplied.
func TestRunStreamEventsRequiresSessionID(t *testing.T) {
	var stdout, stderr bytes.Buffer
	err := runWithIO(t.Context(), []string{"stream-events", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
	if err == nil || !strings.Contains(err.Error(), "session-id is required") {
		t.Fatalf("stream-events without -session-id error = %v", err)
	}
}

// TestRunAdviseSupervisoryRequiresSessionID pins the session-id guard so
// advise-supervisory fails fast before dialing when no session id is supplied.
func TestRunAdviseSupervisoryRequiresSessionID(t *testing.T) {
	var stdout, stderr bytes.Buffer
	err := runWithIO(t.Context(), []string{"advise-supervisory", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
	if err == nil || !strings.Contains(err.Error(), "session-id is required") {
		t.Fatalf("advise-supervisory without -session-id error = %v", err)
	}
}

// TestRunWriteSecuredRequiresSessionID pins the write-secured session-id guard so
// it fails fast before dialing.
func TestRunWriteSecuredRequiresSessionID(t *testing.T) {
	var stdout, stderr bytes.Buffer
	err := runWithIO(t.Context(), []string{"write-secured", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
	if err == nil || !strings.Contains(err.Error(), "session-id is required") {
		t.Fatalf("write-secured without -session-id error = %v", err)
	}
}

// TestRunAuthenticateUserRequiresVerifyUser pins that authenticate-user fails fast
// (before dialing) when the user is absent, and never echoes any credential in
// the guard error.
func TestRunAuthenticateUserRequiresVerifyUser(t *testing.T) {
	var stdout, stderr bytes.Buffer
	err := runWithIO(t.Context(), []string{
		"authenticate-user",
		"-session-id", "s1",
		"-password", "hunter2-password",
		"-plaintext",
		"-api-key", "test",
	}, &stdout, &stderr)
	if err == nil || !strings.Contains(err.Error(), "verify-user is required") {
		t.Fatalf("authenticate-user without -verify-user error = %v", err)
	}
	if strings.Contains(err.Error(), "hunter2-password") {
		t.Fatalf("authenticate-user guard error leaked the credential: %v", err)
	}
}

// TestRunAuthenticateUserRejectsEmptyPassword pins the CLI-45 fail-fast contract:
// an unresolved credential must abort before dialing rather than authenticating
// with an empty password, and the usage error must name both -password and the
// canonical environment variable without echoing any value.
func TestRunAuthenticateUserRejectsEmptyPassword(t *testing.T) {
	t.Setenv("MXGATEWAY_VERIFY_PASSWORD", "")

	var stdout, stderr bytes.Buffer
	err := runWithIO(t.Context(), []string{
		"authenticate-user",
		"-session-id", "s1",
		"-verify-user", "operator",
		"-plaintext",
		"-api-key", "test",
	}, &stdout, &stderr)
	if err == nil {
		t.Fatal("authenticate-user without a credential must fail before dialing")
	}
	if !strings.Contains(err.Error(), "-password") {
		t.Fatalf("error must name the -password flag: %v", err)
	}
	if !strings.Contains(err.Error(), "MXGATEWAY_VERIFY_PASSWORD") {
		t.Fatalf("error must name the canonical environment variable: %v", err)
	}
}

// TestRunAuthenticateUserReadsPasswordFromCanonicalEnv pins that the default
// -password-env is MXGATEWAY_VERIFY_PASSWORD: with it set the credential guard
// passes and the command proceeds past it to the dial, which fails against an
// unused port under a short context — proving the guard was cleared without
// needing a live gateway.
func TestRunAuthenticateUserReadsPasswordFromCanonicalEnv(t *testing.T) {
	t.Setenv("MXGATEWAY_VERIFY_PASSWORD", "env-sourced-credential")

	ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
	defer cancel()

	var stdout, stderr bytes.Buffer
	err := runWithIO(ctx, []string{
		"authenticate-user",
		"-session-id", "s1",
		"-verify-user", "operator",
		"-endpoint", "127.0.0.1:1",
		"-plaintext",
		"-api-key", "test",
		"-call-timeout", "1s",
	}, &stdout, &stderr)
	if err == nil {
		t.Fatal("expected the dial/RPC to fail against an unused port")
	}
	if strings.Contains(err.Error(), "flag provided but not defined") {
		t.Fatalf("test invoked an unknown flag, so it never reached the guard: %v", err)
	}
	if strings.Contains(err.Error(), "a password is required") {
		t.Fatalf("credential guard must be satisfied from %s: %v", "MXGATEWAY_VERIFY_PASSWORD", err)
	}
	if strings.Contains(err.Error(), "env-sourced-credential") ||
		strings.Contains(stdout.String(), "env-sourced-credential") ||
		strings.Contains(stderr.String(), "env-sourced-credential") {
		t.Fatal("the resolved credential must never be echoed")
	}
}

// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
// guard so a write-bulk with unequal item-handles / values counts fails fast
// before any dial.
func TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues(t *testing.T) {
	var stdout, stderr bytes.Buffer
	err := runWithIO(t.Context(), []string{
		"write-bulk",
		"-session-id", "s1",
		"-server-handle", "1",
		"-item-handles", "1,2",
		"-values", "10",
		"-type", "int32",
		"-plaintext",
		"-api-key", "test",
	}, &stdout, &stderr)
	if err == nil || !strings.Contains(err.Error(), "does not match values count") {
		t.Fatalf("write-bulk mismatched handles/values error = %v", err)
	}
}

// replayGapFakeGateway streams the gateway's reconnect-replay sentinel (an MxEvent
// carrying replay_gap, family UNSPECIFIED, body unset) followed by one normal data
// event — exactly what a resume whose cursor predates the retained replay ring sees.
type replayGapFakeGateway struct {
	pb.UnimplementedMxAccessGatewayServer
}

func (g *replayGapFakeGateway) StreamEvents(
	req *pb.StreamEventsRequest,
	stream grpc.ServerStreamingServer[pb.MxEvent],
) error {
	sentinel := &pb.MxEvent{
		SessionId: req.GetSessionId(),
		Family:    pb.MxEventFamily_MX_EVENT_FAMILY_UNSPECIFIED,
		ReplayGap: &pb.ReplayGap{
			RequestedAfterSequence:  7,
			OldestAvailableSequence: 42,
		},
	}
	if err := stream.Send(sentinel); err != nil {
		return err
	}
	return stream.Send(&pb.MxEvent{
		SessionId:      req.GetSessionId(),
		Family:         pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE,
		WorkerSequence: 43,
	})
}

func startReplayGapGateway(t *testing.T) string {
	t.Helper()

	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	server := grpc.NewServer()
	pb.RegisterMxAccessGatewayServer(server, &replayGapFakeGateway{})
	go func() { _ = server.Serve(listener) }()
	t.Cleanup(func() {
		server.Stop()
		_ = listener.Close()
	})
	return listener.Addr().String()
}

// TestRunStreamEventsPrintsReplayGap pins CLI-36: the CLI must render the typed
// ReplayGap signal in both output modes instead of formatting the library's
// cleared Event field (which printed "0 MX_EVENT_FAMILY_UNSPECIFIED" in text mode
// and an empty object in JSON mode, destroying the resume cursors).
func TestRunStreamEventsPrintsReplayGap(t *testing.T) {
	endpoint := startReplayGapGateway(t)

	baseArgs := []string{
		"stream-events",
		"-endpoint", endpoint,
		"-plaintext",
		"-api-key", "test",
		"-session-id", "gap-session",
		"-after-worker-sequence", "7",
		"-limit", "2",
	}

	var stdout, stderr bytes.Buffer
	if err := runWithIO(t.Context(), baseArgs, &stdout, &stderr); err != nil {
		t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
	}
	text := stdout.String()
	if !strings.Contains(text, "REPLAY_GAP requested_after=7 oldest_available=42") {
		t.Fatalf("stream-events text output missing typed gap row: %q", text)
	}
	if strings.Contains(text, "0 MX_EVENT_FAMILY_UNSPECIFIED") {
		t.Fatalf("stream-events text output destroyed the gap into a zero row: %q", text)
	}
	if !strings.Contains(text, "43 MX_EVENT_FAMILY_ON_DATA_CHANGE") {
		t.Fatalf("stream-events text output dropped the normal event: %q", text)
	}

	stdout.Reset()
	stderr.Reset()
	if err := runWithIO(t.Context(), append(baseArgs, "-json"), &stdout, &stderr); err != nil {
		t.Fatalf("runWithIO(-json) error = %v; stderr = %s", err, stderr.String())
	}

	lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
	if len(lines) != 2 {
		t.Fatalf("stream-events -json emitted %d rows, want 2: %q", len(lines), stdout.String())
	}

	// The cursors must decode as JSON numbers, not the strings the proto3 JSON
	// mapping would produce for 64-bit fields: the Rust and Python CLIs emit
	// numbers, and the cross-language matrix compares these rows across clients.
	var gapRow struct {
		ReplayGap *struct {
			RequestedAfterSequence  uint64 `json:"requestedAfterSequence"`
			OldestAvailableSequence uint64 `json:"oldestAvailableSequence"`
		} `json:"replayGap"`
	}
	if err := json.Unmarshal([]byte(lines[0]), &gapRow); err != nil {
		t.Fatalf("parse gap row: %v\nrow: %s", err, lines[0])
	}
	if gapRow.ReplayGap == nil {
		t.Fatalf("stream-events -json first row is not a replayGap row: %s", lines[0])
	}
	if gapRow.ReplayGap.RequestedAfterSequence != 7 || gapRow.ReplayGap.OldestAvailableSequence != 42 {
		t.Fatalf("stream-events -json gap cursors = %+v, want 7/42", *gapRow.ReplayGap)
	}
	// Belt and braces on the value type: a protojson-rendered `"7"` already
	// fails the decode above (encoding/json rejects a JSON string for an
	// untagged uint64 field), but assert the raw bytes so a regression names
	// the real problem instead of surfacing as an opaque unmarshal error.
	if !strings.Contains(lines[0], `"requestedAfterSequence":7`) ||
		!strings.Contains(lines[0], `"oldestAvailableSequence":42`) {
		t.Fatalf("stream-events -json gap cursors must be JSON numbers, got: %s", lines[0])
	}
}
