package gitosync

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

	toki "git.toki-labs.com/toki/proto-socket/go"
	"git.toki-labs.com/toki/proto-socket/go/packets"
	"github.com/nomadcode/nomadcode-core/internal/gitoevents"
	"github.com/nomadcode/nomadcode-core/internal/protosocket"
	"github.com/nomadcode/nomadcode-core/internal/scheduler"
	"github.com/nomadcode/nomadcode-core/internal/workitem"
	"google.golang.org/protobuf/types/known/structpb"
	"nhooyr.io/websocket"
)

// branchUpdatedEnvelope builds a contract-shaped broadcast envelope fixture.
func branchUpdatedEnvelope() protosocket.Envelope {
	return protosocket.Envelope{
		ProtocolVersion: protosocket.ProtocolVersion,
		ID:              "event-id",
		Type:            "event",
		Channel:         "event",
		Action:          "branch.updated",
		Payload: map[string]any{
			"id":          "event-id",
			"type":        "branch.updated",
			"provider":    "forgejo",
			"delivery_id": "delivery-id",
			"repo_id":     "nomadcode",
			"branch":      "develop",
			"before":      "old-sha",
			"after":       "new-sha",
			"changed_files": []any{
				map[string]any{
					"path":        "agent-roadmap/phase/example/milestones/example.md",
					"change_type": "modified",
				},
				map[string]any{
					"path":        "README.md",
					"change_type": "modified",
				},
			},
			"observed_at": "2026-06-13T00:00:00Z",
			"created_at":  "2026-06-13T00:00:00Z",
		},
	}
}

// TestProtoSocketTransportConnectAndClose verifies basic connect/close using toki.WsClient.
func TestProtoSocketTransportConnectAndClose(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, nil)
		if err != nil {
			return
		}
		defer conn.Close(websocket.StatusInternalError, "")

		ctx := r.Context()
		for {
			_, _, err := conn.Read(ctx)
			if err != nil {
				break
			}
		}
	}))
	defer srv.Close()

	wsURL := strings.Replace(srv.URL, "http", "ws", 1)
	transport := &protoSocketTransport{url: wsURL}

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	if err := transport.Connect(ctx); err != nil {
		t.Fatalf("Connect failed: %v", err)
	}

	if transport.client == nil || !transport.client.IsAlive() {
		t.Fatal("client should be alive")
	}

	if err := transport.Close(); err != nil {
		t.Fatalf("Close failed: %v", err)
	}
}

// TestProtoSocketTransportOverflowAndDisconnect implements REVIEW_API-1.
// It proves backpressure on overflow and that close/disconnect terminal signals
// are not silently dropped.
func TestProtoSocketTransportOverflowAndDisconnect(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, nil)
		if err != nil {
			return
		}

		serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
		defer serverClient.Close()

		// Send 3 events.
		// Since buffer capacity is 1, the client will block (backpressure) during the second or third send.
		eventEnv := branchUpdatedEnvelope()
		eventStruct, _ := eventEnv.ToStruct()

		_ = serverClient.Send(eventStruct)
		_ = serverClient.Send(eventStruct)
		_ = serverClient.Send(eventStruct)

		// Wait briefly then close the server-side client to trigger a disconnect
		time.Sleep(100 * time.Millisecond)
	}))
	defer srv.Close()

	wsURL := strings.Replace(srv.URL, "http", "ws", 1)
	transport := &protoSocketTransport{
		url:        wsURL,
		inboundCap: 1, // Set small buffer capacity to test overflow/backpressure
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := transport.Connect(ctx); err != nil {
		t.Fatalf("Connect failed: %v", err)
	}
	defer transport.Close()

	// Wait to let server send events and then disconnect
	time.Sleep(200 * time.Millisecond)

	// We expect to read events successfully, and finally observe the disconnect EOF without getting stuck.
	// 1st Read
	env1, err := transport.Read(ctx)
	if err != nil {
		t.Fatalf("Read 1 failed: %v", err)
	}
	if env1.Action != "branch.updated" {
		t.Errorf("expected branch.updated event, got: %s", env1.Action)
	}

	// 2nd Read
	env2, err := transport.Read(ctx)
	if err != nil {
		t.Fatalf("Read 2 failed: %v", err)
	}
	if env2.Action != "branch.updated" {
		t.Errorf("expected branch.updated event, got: %s", env2.Action)
	}

	// 3rd Read (Could either be 3rd event or EOF, depending on toki read timing, but must return either)
	_, err = transport.Read(ctx)
	// Eventually it must return EOF. We do another read just to ensure EOF.
	_, err = transport.Read(ctx)
	if err == nil {
		t.Fatal("expected terminal EOF error, but got nil")
	}
	if !errors.Is(err, io.EOF) && !strings.Contains(err.Error(), "context") {
		t.Fatalf("expected EOF error, got: %v", err)
	}
}

// TestProtoSocketTransportClientRunDeliversBinaryBranchUpdatedEvent implements REVIEW_API-2.
// It verifies that gitoevents.Client.Run works with the production transport and mock server.
func TestProtoSocketTransportClientRunDeliversBinaryBranchUpdatedEvent(t *testing.T) {
	subscribeCh := make(chan *structpb.Struct, 1)

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, nil)
		if err != nil {
			return
		}

		serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
		defer serverClient.Close()

		toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
			env, err := protosocket.EnvelopeFromStruct(req)
			if err != nil {
				return nil, err
			}

			if env.Channel == "event" && env.Action == "event.subscribe" {
				subscribeCh <- req

				resEnv := protosocket.Envelope{
					ProtocolVersion: protosocket.ProtocolVersion,
					ID:              "res-id",
					CorrelationID:   env.ID,
					Type:            "response",
					Channel:         "event",
					Action:          "event.subscribe",
				}
				resStruct, _ := resEnv.ToStruct()

				go func() {
					time.Sleep(50 * time.Millisecond)
					eventEnv := branchUpdatedEnvelope()
					eventStruct, _ := eventEnv.ToStruct()
					_ = serverClient.Send(eventStruct)
				}()

				return resStruct, nil
			}
			return nil, errors.New("unexpected request")
		})

		<-r.Context().Done()
	}))
	defer srv.Close()

	wsURL := strings.Replace(srv.URL, "http", "ws", 1)
	transport := &protoSocketTransport{url: wsURL}

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

	handlerCh := make(chan gitoevents.BranchUpdatedEvent, 1)
	handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {
		handlerCh <- ev
	}

	client, err := gitoevents.NewClient(transport, gitoevents.Options{
		RepoID:  "nomadcode",
		Branch:  "develop",
		Handler: handler,
	})
	if err != nil {
		t.Fatalf("NewClient failed: %v", err)
	}

	runErrCh := make(chan error, 1)
	go func() {
		runErrCh <- client.Run(ctx)
	}()

	// Verify subscribe request was received on the server
	select {
	case reqStruct := <-subscribeCh:
		env, err := protosocket.EnvelopeFromStruct(reqStruct)
		if err != nil {
			t.Fatalf("invalid subscribe request: %v", err)
		}
		if env.Channel != "event" || env.Action != "event.subscribe" {
			t.Fatalf("unexpected subscribe request: %+v", env)
		}
		events, ok := env.Payload["events"].([]any)
		if !ok || len(events) != 1 || events[0] != "branch.updated" {
			t.Fatalf("unexpected subscribe request events: %v", env.Payload["events"])
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for subscribe request")
	}

	// Verify event was received by the handler
	select {
	case ev := <-handlerCh:
		if ev.RepoID != "nomadcode" || ev.Branch != "develop" || ev.After != "new-sha" {
			t.Fatalf("unexpected branch updated event: %+v", ev)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for branch updated event")
	}

	// Cancel context and verify client exits cleanly
	cancel()
	select {
	case runErr := <-runErrCh:
		if runErr != nil {
			t.Fatalf("expected nil error on clean exit, got: %v", runErr)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for Client.Run to exit")
	}
}

// TestProtoSocketTransportClientRunDropsMalformedAndMismatchedStructs implements REVIEW_API-3.
// It verifies that malformed or mismatched structpb.Struct envelopes are safely ignored
// without causing handler side effects or breaking the stream.
func TestProtoSocketTransportClientRunDropsMalformedAndMismatchedStructs(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, nil)
		if err != nil {
			return
		}

		serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
		defer serverClient.Close()

		toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
			env, _ := protosocket.EnvelopeFromStruct(req)
			if env.Action == "event.subscribe" {
				resEnv := protosocket.Envelope{
					ProtocolVersion: protosocket.ProtocolVersion,
					ID:              "res-id",
					CorrelationID:   env.ID,
					Type:            "response",
					Channel:         "event",
					Action:          "event.subscribe",
				}
				resStruct, _ := resEnv.ToStruct()

				go func() {
					time.Sleep(50 * time.Millisecond)

					// 1. Wrong channel/action
					wrongEnv := branchUpdatedEnvelope()
					wrongEnv.Channel = "wrong"
					wrongEnv.Action = "wrong"
					wrongStruct, _ := wrongEnv.ToStruct()
					_ = serverClient.Send(wrongStruct)

					// 2. Missing payload
					missingEnv := branchUpdatedEnvelope()
					missingEnv.Payload = nil
					missingStruct, _ := missingEnv.ToStruct()
					_ = serverClient.Send(missingStruct)

					// 3. Malformed changed_files
					malformedEnv := branchUpdatedEnvelope()
					malformedEnv.Payload["changed_files"] = "not-a-list"
					malformedStruct, _ := malformedEnv.ToStruct()
					_ = serverClient.Send(malformedStruct)

					// Wait a bit to ensure Client.handleEnvelope processes drops
					time.Sleep(50 * time.Millisecond)

					// 4. Send valid target event
					validEnv := branchUpdatedEnvelope()
					validStruct, _ := validEnv.ToStruct()
					_ = serverClient.Send(validStruct)
				}()

				return resStruct, nil
			}
			return nil, errors.New("unexpected request")
		})

		<-r.Context().Done()
	}))
	defer srv.Close()

	wsURL := strings.Replace(srv.URL, "http", "ws", 1)
	transport := &protoSocketTransport{url: wsURL}

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

	handlerCh := make(chan gitoevents.BranchUpdatedEvent, 10)
	handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {
		handlerCh <- ev
	}

	client, err := gitoevents.NewClient(transport, gitoevents.Options{
		RepoID:  "nomadcode",
		Branch:  "develop",
		Handler: handler,
	})
	if err != nil {
		t.Fatalf("NewClient failed: %v", err)
	}

	go func() {
		_ = client.Run(ctx)
	}()

	// We expect exactly one event (the valid on-target one) to reach the handler.
	select {
	case ev := <-handlerCh:
		if ev.RepoID != "nomadcode" || ev.Branch != "develop" {
			t.Fatalf("unexpected event: %+v", ev)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for valid event")
	}

	// Verify no other events were received
	select {
	case extra := <-handlerCh:
		t.Fatalf("received unexpected extra event: %+v", extra)
	default:
		// success
	}
}

// channelEnqueuer captures EnqueueRoadmapCreationSync calls on a buffered
// channel so e2e tests can wait for enqueue completion without polling.
type channelEnqueuer struct {
	ch chan scheduler.RoadmapCreationSyncJobArgs
}

// recordingScanner records the BranchUpdatedEvent it receives and returns
// matchedScanOutput() with Result.Revision.Revision overridden to ev.After.
// This ties the e2e job's RoadmapRevision to the wire event's actual after
// value, so a revision handoff break would be caught by the test assertion.
type recordingScanner struct {
	eventCh chan gitoevents.BranchUpdatedEvent
}

func newRecordingScanner() *recordingScanner {
	return &recordingScanner{eventCh: make(chan gitoevents.BranchUpdatedEvent, 4)}
}

func (s *recordingScanner) Scan(_ context.Context, ev gitoevents.BranchUpdatedEvent) (ScanOutput, error) {
	s.eventCh <- ev
	out := matchedScanOutput()
	out.Result.Revision.Revision = ev.After
	return out, nil
}

func (e *channelEnqueuer) EnqueueRoadmapCreationSync(_ context.Context, args scheduler.RoadmapCreationSyncJobArgs) error {
	e.ch <- args
	return nil
}

// gotoWireServer returns an httptest.Server that speaks binary proto-socket,
// responds to the event.subscribe request, and broadcasts each env in events
// after a 50 ms delay. The caller owns closing the server.
func gotoWireServer(t *testing.T, events []protosocket.Envelope) *httptest.Server {
	t.Helper()
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, nil)
		if err != nil {
			return
		}
		serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
		defer serverClient.Close()

		toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
			env, err := protosocket.EnvelopeFromStruct(req)
			if err != nil {
				return nil, err
			}
			if env.Channel != gitoevents.EventChannel || env.Action != gitoevents.SubscribeAction {
				return nil, errors.New("unexpected request")
			}
			resEnv := protosocket.Envelope{
				ProtocolVersion: protosocket.ProtocolVersion,
				ID:              "res-1",
				CorrelationID:   env.ID,
				Type:            "response",
				Channel:         gitoevents.EventChannel,
				Action:          gitoevents.SubscribeAction,
			}
			resStruct, _ := resEnv.ToStruct()
			go func() {
				time.Sleep(50 * time.Millisecond)
				for _, ev := range events {
					s, _ := ev.ToStruct()
					_ = serverClient.Send(s)
					// Give the bridge time to finish Handle (MarkProcessed) before
					// sending the next event so dedup is observable.
					time.Sleep(100 * time.Millisecond)
				}
			}()
			return resStruct, nil
		})
		<-r.Context().Done()
	}))
}

// TestWireConsumerEventEnqueuesCreationSyncOnce verifies the full wire path:
// binary proto-socket transport → gitoevents.Client → Bridge.Handle → Enqueuer.
// EnqueueRoadmapCreationSync must be called exactly once with the expected args.
// The recordingScanner reflects ev.After into RoadmapRevision, so this test
// also verifies that the wire event's revision is forwarded through the path.
func TestWireConsumerEventEnqueuesCreationSyncOnce(t *testing.T) {
	jobCh := make(chan scheduler.RoadmapCreationSyncJobArgs, 4)
	enq := &channelEnqueuer{ch: jobCh}
	reader := &fakeReader{item: workitem.WorkItem{DescriptionHTML: "<p>html body</p>"}}
	scanner := newRecordingScanner()

	bridge, err := NewBridge(scanner, reader, enq,
		NewInMemoryRevisionStore(), BridgeConfig{
			RepoID:      "nomadcode",
			Branch:      "develop",
			TodoStateID: "state-todo",
		}, nil)
	if err != nil {
		t.Fatalf("NewBridge: %v", err)
	}

	srv := gotoWireServer(t, []protosocket.Envelope{branchUpdatedEnvelope()})
	defer srv.Close()

	wsURL := strings.Replace(srv.URL, "http", "ws", 1)
	client, err := NewRunner(wsURL, "nomadcode", "develop", bridge, nil)
	if err != nil {
		t.Fatalf("NewRunner: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go func() { _ = client.Run(ctx) }()

	var job scheduler.RoadmapCreationSyncJobArgs
	select {
	case job = <-jobCh:
	case <-time.After(3 * time.Second):
		t.Fatal("timed out waiting for EnqueueRoadmapCreationSync")
	}

	// Validate the scanner received the wire event with expected fields.
	// By the time jobCh has a value, scanner.eventCh already has the event.
	var scannedEv gitoevents.BranchUpdatedEvent
	select {
	case scannedEv = <-scanner.eventCh:
	default:
		t.Fatal("recordingScanner was not called with the wire event")
	}
	if scannedEv.RepoID != "nomadcode" || scannedEv.Branch != "develop" {
		t.Fatalf("scanner event repo/branch: got %q/%q", scannedEv.RepoID, scannedEv.Branch)
	}
	if scannedEv.Before != "old-sha" || scannedEv.After != "new-sha" {
		t.Fatalf("scanner event before/after: got %q/%q", scannedEv.Before, scannedEv.After)
	}
	if len(scannedEv.MilestoneChangedFiles()) == 0 {
		t.Fatal("scanner event: expected at least one milestone changed file")
	}

	// Job args assertions.
	if job.Expected.WorkItemID != "wi-123" {
		t.Fatalf("Expected.WorkItemID: got %q, want wi-123", job.Expected.WorkItemID)
	}
	if job.Ref.Provider != "plane" || job.Ref.ID != "wi-123" || job.Ref.Project != "proj-1" {
		t.Fatalf("Ref: got %+v", job.Ref)
	}
	if job.TodoStateID != "state-todo" {
		t.Fatalf("TodoStateID: got %q", job.TodoStateID)
	}
	// recordingScanner sets Revision.Revision to ev.After; the bridge copies it
	// into RoadmapRevision, so this asserts the wire revision is forwarded.
	if job.RoadmapRevision != "new-sha" {
		t.Fatalf("RoadmapRevision: got %q, want new-sha (wire event after)", job.RoadmapRevision)
	}
	if job.MilestoneMarkdown != milestoneMarkdownWithIdentity {
		t.Fatalf("MilestoneMarkdown mismatch")
	}
	if job.OriginalBody != "<p>html body</p>" {
		t.Fatalf("OriginalBody: got %q", job.OriginalBody)
	}
	if job.ExternalSource != "nomadcode" || job.ExternalID != "wi-123" {
		t.Fatalf("ExternalTags: source=%q id=%q", job.ExternalSource, job.ExternalID)
	}
	if job.ProviderRevision != "" {
		t.Fatalf("ProviderRevision: got %q, want empty (mutable Plane state must not be a revision)", job.ProviderRevision)
	}

	// One event was sent; verify no second enqueue occurred.
	select {
	case extra := <-jobCh:
		t.Fatalf("unexpected second enqueue: %+v", extra)
	default:
	}
}

// TestWireConsumerDuplicateEventDoesNotReEnqueue sends the same branch.updated
// event twice over the wire. The bridge's InMemoryRevisionStore must deduplicate
// at the (repo, branch, after) key so only one creation sync job is enqueued.
func TestWireConsumerDuplicateEventDoesNotReEnqueue(t *testing.T) {
	jobCh := make(chan scheduler.RoadmapCreationSyncJobArgs, 4)
	enq := &channelEnqueuer{ch: jobCh}
	reader := &fakeReader{item: workitem.WorkItem{DescriptionText: "body"}}

	bridge, err := NewBridge(newRecordingScanner(), reader, enq,
		NewInMemoryRevisionStore(), BridgeConfig{
			RepoID:      "nomadcode",
			Branch:      "develop",
			TodoStateID: "state-todo",
		}, nil)
	if err != nil {
		t.Fatalf("NewBridge: %v", err)
	}

	ev := branchUpdatedEnvelope()
	srv := gotoWireServer(t, []protosocket.Envelope{ev, ev})
	defer srv.Close()

	wsURL := strings.Replace(srv.URL, "http", "ws", 1)
	client, err := NewRunner(wsURL, "nomadcode", "develop", bridge, nil)
	if err != nil {
		t.Fatalf("NewRunner: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go func() { _ = client.Run(ctx) }()

	// First event must produce exactly one enqueue.
	select {
	case <-jobCh:
	case <-time.After(3 * time.Second):
		t.Fatal("timed out waiting for first EnqueueRoadmapCreationSync")
	}

	// The duplicate (same after="new-sha") must be silently dropped by the
	// InMemoryRevisionStore. Wait long enough for the wire + bridge path to
	// complete, then assert no second job arrived.
	select {
	case extra := <-jobCh:
		t.Fatalf("duplicate event must not re-enqueue: %+v", extra)
	case <-time.After(500 * time.Millisecond):
		// No second enqueue — expected.
	}
}

func TestRunnerStopsCleanlyOnContextCancel(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, nil)
		if err != nil {
			return
		}
		serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
		defer serverClient.Close()

		toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
			env, _ := protosocket.EnvelopeFromStruct(req)
			if env.Action == "event.subscribe" {
				resEnv := protosocket.Envelope{
					ProtocolVersion: protosocket.ProtocolVersion,
					ID:              "res-id",
					CorrelationID:   env.ID,
					Type:            "response",
					Channel:         "event",
					Action:          "event.subscribe",
				}
				resStruct, _ := resEnv.ToStruct()
				return resStruct, nil
			}
			return nil, errors.New("unexpected request")
		})

		<-r.Context().Done()
	}))
	defer srv.Close()

	wsURL := strings.Replace(srv.URL, "http", "ws", 1)
	transport := &protoSocketTransport{url: wsURL}

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

	client, err := gitoevents.NewClient(transport, gitoevents.Options{
		RepoID:  "nomadcode",
		Branch:  "develop",
		Handler: func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {},
	})
	if err != nil {
		t.Fatalf("NewClient failed: %v", err)
	}

	runner := NewReconnectingRunner(client, nil)

	runErrCh := make(chan error, 1)
	go func() {
		runErrCh <- runner.Run(ctx)
	}()

	time.Sleep(100 * time.Millisecond)

	cancel()
	select {
	case runErr := <-runErrCh:
		if runErr != nil {
			t.Fatalf("expected nil error on context cancel, got: %v", runErr)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for runner to exit")
	}
}

func TestRunnerReconnectsAfterUnexpectedDisconnect(t *testing.T) {
	connCountCh := make(chan int, 5)
	subscribeCh := make(chan struct{}, 5)

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, nil)
		if err != nil {
			return
		}
		connCountCh <- 1

		serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())

		toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
			env, _ := protosocket.EnvelopeFromStruct(req)
			if env.Action == "event.subscribe" {
				subscribeCh <- struct{}{}

				resEnv := protosocket.Envelope{
					ProtocolVersion: protosocket.ProtocolVersion,
					ID:              "res-id",
					CorrelationID:   env.ID,
					Type:            "response",
					Channel:         "event",
					Action:          "event.subscribe",
				}
				resStruct, _ := resEnv.ToStruct()

				go func() {
					time.Sleep(50 * time.Millisecond)
					serverClient.Close()
				}()

				return resStruct, nil
			}
			return nil, errors.New("unexpected request")
		})

		<-r.Context().Done()
	}))
	defer srv.Close()

	wsURL := strings.Replace(srv.URL, "http", "ws", 1)
	transport := &protoSocketTransport{url: wsURL}

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

	client, err := gitoevents.NewClient(transport, gitoevents.Options{
		RepoID:  "nomadcode",
		Branch:  "develop",
		Handler: func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {},
	})
	if err != nil {
		t.Fatalf("NewClient failed: %v", err)
	}

	runner := NewReconnectingRunner(client, nil)
	runner.MaxRetries = 2
	runner.BaseDelay = 50 * time.Millisecond
	runner.MaxDelay = 100 * time.Millisecond

	go func() {
		_ = runner.Run(ctx)
	}()

	select {
	case <-connCountCh:
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for 1st connection")
	}

	select {
	case <-subscribeCh:
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for 1st subscribe")
	}

	select {
	case <-connCountCh:
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for 2nd connection (reconnect)")
	}

	select {
	case <-subscribeCh:
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for 2nd subscribe (reconnect)")
	}
}

func TestRunnerDropsMalformedBinaryFrameWithoutEnqueue(t *testing.T) {
	connCountCh := make(chan int, 5)
	handlerCh := make(chan gitoevents.BranchUpdatedEvent, 5)
	var mu sync.Mutex
	var connCounter int

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, nil)
		if err != nil {
			return
		}

		mu.Lock()
		connCounter++
		id := connCounter
		mu.Unlock()
		connCountCh <- id

		serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
		defer serverClient.Close()

		toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
			env, _ := protosocket.EnvelopeFromStruct(req)
			if env.Action == "event.subscribe" {
				resEnv := protosocket.Envelope{
					ProtocolVersion: protosocket.ProtocolVersion,
					ID:              "res-id",
					CorrelationID:   env.ID,
					Type:            "response",
					Channel:         "event",
					Action:          "event.subscribe",
				}
				resStruct, _ := resEnv.ToStruct()

				go func() {
					time.Sleep(50 * time.Millisecond)

					if id == 1 {
						// Case A: Send raw malformed binary to trigger connection drop.
						_ = conn.Write(r.Context(), websocket.MessageBinary, []byte{0xff, 0xff, 0xff})
					} else {
						// Case B: Send packets.PacketBase with google.protobuf.Struct TypeName but corrupted Data.
						// This tests packet parse failure on payload level inside OnReceivedData.
						writeErr := serverClient.WritePacket(&packets.PacketBase{
							TypeName: "google.protobuf.Struct",
							Nonce:    999,
							Data:     []byte{0xff, 0xff, 0xff},
						})
						if writeErr != nil {
							t.Errorf("failed to write malformed payload packet: %v", writeErr)
						}

						// Case C: Send valid event afterwards to ensure client is still alive and processes normal events.
						time.Sleep(50 * time.Millisecond)
						validEnv := branchUpdatedEnvelope()
						validStruct, _ := validEnv.ToStruct()
						_ = serverClient.Send(validStruct)
					}
				}()

				return resStruct, nil
			}
			return nil, errors.New("unexpected request")
		})

		<-r.Context().Done()
	}))
	defer srv.Close()

	wsURL := strings.Replace(srv.URL, "http", "ws", 1)
	transport := &protoSocketTransport{url: wsURL}

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

	logger := slog.Default()
	client, err := gitoevents.NewClient(transport, gitoevents.Options{
		RepoID:  "nomadcode",
		Branch:  "develop",
		Handler: func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {
			handlerCh <- ev
		},
		Logger: logger,
	})
	if err != nil {
		t.Fatalf("NewClient failed: %v", err)
	}

	runner := NewReconnectingRunner(client, logger)
	runner.MaxRetries = 2
	runner.BaseDelay = 50 * time.Millisecond
	runner.MaxDelay = 100 * time.Millisecond

	go func() {
		_ = runner.Run(ctx)
	}()

	// 1st Connection: client gets malformed raw binary -> disconnects -> reconnects.
	select {
	case <-connCountCh:
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for 1st connection")
	}

	// 2nd Connection: client reconnects -> receives malformed payload (drop) -> receives valid event (process).
	select {
	case <-connCountCh:
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for 2nd connection (reconnect)")
	}

	// Expect exactly one valid event on handlerCh
	select {
	case ev := <-handlerCh:
		if ev.RepoID != "nomadcode" || ev.Branch != "develop" {
			t.Fatalf("unexpected event: %+v", ev)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for valid event after malformed inputs")
	}

	// Verify no extra events were received
	select {
	case extra := <-handlerCh:
		t.Fatalf("received unexpected extra event: %+v", extra)
	default:
		// success
	}
}
