package recorder

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/go-gost/core/recorder"
	xlogger "github.com/go-gost/x/logger"
)

// --- Option tests ---

func TestRecorderFileRecorderOption(t *testing.T) {
	var opts fileRecorderOptions
	RecorderFileRecorderOption("my-recorder")(&opts)
	if opts.recorder != "my-recorder" {
		t.Errorf("recorder = %s, want my-recorder", opts.recorder)
	}
}

func TestSepFileRecorderOption(t *testing.T) {
	var opts fileRecorderOptions
	SepFileRecorderOption("\n")(&opts)
	if opts.sep != "\n" {
		t.Errorf("sep = %s, want \\n", opts.sep)
	}
}

func TestRecorderHTTPRecorderOption(t *testing.T) {
	var opts httpRecorderOptions
	RecorderHTTPRecorderOption("http-rec")(&opts)
	if opts.recorder != "http-rec" {
		t.Errorf("recorder = %s, want http-rec", opts.recorder)
	}
}

func TestTimeoutHTTPRecorderOption(t *testing.T) {
	var opts httpRecorderOptions
	TimeoutHTTPRecorderOption(5 * time.Second)(&opts)
	if opts.timeout != 5*time.Second {
		t.Errorf("timeout = %v, want 5s", opts.timeout)
	}
}

func TestHeaderHTTPRecorderOption(t *testing.T) {
	var opts httpRecorderOptions
	h := http.Header{"X-Test": []string{"value"}}
	HeaderHTTPRecorderOption(h)(&opts)
	if opts.header.Get("X-Test") != "value" {
		t.Errorf("header = %v, want X-Test: value", opts.header)
	}
}

func TestRecorderTCPRecorderOption(t *testing.T) {
	var opts tcpRecorderOptions
	RecorderTCPRecorderOption("tcp-rec")(&opts)
	if opts.recorder != "tcp-rec" {
		t.Errorf("recorder = %s, want tcp-rec", opts.recorder)
	}
}

func TestTimeoutTCPRecorderOption(t *testing.T) {
	var opts tcpRecorderOptions
	TimeoutTCPRecorderOption(10 * time.Second)(&opts)
	if opts.timeout != 10*time.Second {
		t.Errorf("timeout = %v, want 10s", opts.timeout)
	}
}

func TestLogTCPRecorderOption(t *testing.T) {
	var opts tcpRecorderOptions
	l := xlogger.Nop()
	LogTCPRecorderOption(l)(&opts)
	if opts.log != l {
		t.Error("log should be set")
	}
}

func TestRecorderRedisRecorderOption(t *testing.T) {
	var opts redisRecorderOptions
	RecorderRedisRecorderOption("redis-rec")(&opts)
	if opts.recorder != "redis-rec" {
		t.Errorf("recorder = %s, want redis-rec", opts.recorder)
	}
}

func TestDBRedisRecorderOption(t *testing.T) {
	var opts redisRecorderOptions
	DBRedisRecorderOption(3)(&opts)
	if opts.db != 3 {
		t.Errorf("db = %d, want 3", opts.db)
	}
}

func TestUsernameRedisRecorderOption(t *testing.T) {
	var opts redisRecorderOptions
	UsernameRedisRecorderOption("admin")(&opts)
	if opts.username != "admin" {
		t.Errorf("username = %s, want admin", opts.username)
	}
}

func TestPasswordRedisRecorderOption(t *testing.T) {
	var opts redisRecorderOptions
	PasswordRedisRecorderOption("secret")(&opts)
	if opts.password != "secret" {
		t.Errorf("password = %s, want secret", opts.password)
	}
}

func TestKeyRedisRecorderOption(t *testing.T) {
	var opts redisRecorderOptions
	KeyRedisRecorderOption("mykey")(&opts)
	if opts.key != "mykey" {
		t.Errorf("key = %s, want mykey", opts.key)
	}
}

// --- FileRecorder tests ---

func TestFileRecorder_Record(t *testing.T) {
	var buf bytes.Buffer
	r := FileRecorder(&nopWriteCloser{&buf})
	if r == nil {
		t.Fatal("FileRecorder should not return nil")
	}

	err := r.Record(context.Background(), []byte("hello"))
	if err != nil {
		t.Fatal(err)
	}
	if buf.String() != "hello" {
		t.Errorf("output = %s, want hello", buf.String())
	}
}

func TestFileRecorder_Record_WithSeparator(t *testing.T) {
	var buf bytes.Buffer
	r := FileRecorder(&nopWriteCloser{&buf}, SepFileRecorderOption("\n"))

	err := r.Record(context.Background(), []byte("line1"))
	if err != nil {
		t.Fatal(err)
	}
	err = r.Record(context.Background(), []byte("line2"))
	if err != nil {
		t.Fatal(err)
	}
	if buf.String() != "line1\nline2\n" {
		t.Errorf("output = %q, want line1\\nline2\\n", buf.String())
	}
}

func TestFileRecorder_Record_NilOut(t *testing.T) {
	r := FileRecorder(nil)
	err := r.Record(context.Background(), []byte("should not panic"))
	if err != nil {
		t.Errorf("expected nil on nil out, got %v", err)
	}
}

func TestFileRecorder_Close(t *testing.T) {
	var buf bytes.Buffer
	r := FileRecorder(&nopWriteCloser{&buf}).(*fileRecorder)
	err := r.Close()
	if err != nil {
		t.Fatal(err)
	}
}

func TestFileRecorder_Close_Idempotent(t *testing.T) {
	var buf bytes.Buffer
	r := FileRecorder(&nopWriteCloser{&buf}).(*fileRecorder)
	if err := r.Close(); err != nil {
		t.Fatal(err)
	}
	if err := r.Close(); err != nil {
		t.Errorf("second Close returned error: %v", err)
	}
}

func TestFileRecorder_Close_NilOut(t *testing.T) {
	r := FileRecorder(nil).(*fileRecorder)
	if err := r.Close(); err != nil {
		t.Errorf("Close on nil out returned error: %v", err)
	}
}

func TestFileRecorder_ConcurrentWrites(t *testing.T) {
	var buf bytes.Buffer
	r := FileRecorder(&nopWriteCloser{&buf}, SepFileRecorderOption("\n"))

	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			r.Record(context.Background(), []byte(fmt.Sprintf("msg-%d", n)))
		}(i)
	}
	wg.Wait()

	lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
	if len(lines) != 100 {
		t.Errorf("expected 100 lines, got %d", len(lines))
	}
}

func TestFileRecorder_Record_WriteError(t *testing.T) {
	r := FileRecorder(&nopWriteCloser{&errorWriter{}})
	err := r.Record(context.Background(), []byte("data"))
	if err == nil {
		t.Error("expected error on write failure")
	}
}

func TestFileRecorder_Record_WriteErrorWithSeparator(t *testing.T) {
	w := &writeOnceThenError{max: 0}
	r := FileRecorder(&nopWriteCloser{w}, SepFileRecorderOption("\n"))
	err := r.Record(context.Background(), []byte("data"))
	if err == nil {
		t.Error("expected error on write failure")
	}
}

func TestFileRecorder_ConcurrentWrites_NoSeparator(t *testing.T) {
	var buf bytes.Buffer
	r := FileRecorder(&nopWriteCloser{&buf})

	var wg sync.WaitGroup
	for i := 0; i < 50; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			r.Record(context.Background(), []byte(fmt.Sprintf("msg%d", n)))
		}(i)
	}
	wg.Wait()
	// Without a separator, lines may be interleaved but shouldn't panic.
	if buf.Len() == 0 {
		t.Error("buffer should contain data")
	}
}

func TestFileRecorder_WithRecorder(t *testing.T) {
	var buf bytes.Buffer
	r := FileRecorder(&nopWriteCloser{&buf}, RecorderFileRecorderOption("file-rec"))
	fr := r.(*fileRecorder)
	if fr.recorder != "file-rec" {
		t.Errorf("recorder = %s, want file-rec", fr.recorder)
	}
}

func TestFileRecorder_CloseAfterRecord(t *testing.T) {
	var buf bytes.Buffer
	r := FileRecorder(&nopWriteCloser{&buf}).(*fileRecorder)
	r.Record(context.Background(), []byte("data"))
	if err := r.Close(); err != nil {
		t.Errorf("first Close: %v", err)
	}
	if err := r.Close(); err != nil {
		t.Errorf("second Close: %v", err)
	}
}

// --- HTTPRecorder tests ---

func TestHTTPRecorder_EmptyURL(t *testing.T) {
	r := HTTPRecorder("")
	if r != nil {
		t.Error("HTTPRecorder with empty URL should return nil")
	}
}

func TestHTTPRecorder_NoScheme(t *testing.T) {
	r := HTTPRecorder("example.com:9999")
	hr := r.(*httpRecorder)
	if !strings.HasPrefix(hr.url, "http://") {
		t.Errorf("expected http:// prefix, got %s", hr.url)
	}
	if hr.recorder != "" {
		t.Errorf("recorder = %s, want empty", hr.recorder)
	}
}

func TestHTTPRecorder_KeepsHTTPScheme(t *testing.T) {
	r := HTTPRecorder("https://example.com/api")
	hr := r.(*httpRecorder)
	if !strings.HasPrefix(hr.url, "https://") {
		t.Errorf("expected https:// prefix, got %s", hr.url)
	}
}

func TestHTTPRecorder_WithOptions(t *testing.T) {
	r := HTTPRecorder("http://example.com",
		RecorderHTTPRecorderOption("my-recorder"),
		TimeoutHTTPRecorderOption(5*time.Second),
		HeaderHTTPRecorderOption(http.Header{"X-Custom": []string{"val"}}),
	)
	hr := r.(*httpRecorder)
	// NOTE: recorder field is not propagated from options — a pre-existing bug.
	// The metrics counter in Record will always have recorder="" as the label.
	if hr.httpClient.Timeout != 5*time.Second {
		t.Errorf("timeout = %v, want 5s", hr.httpClient.Timeout)
	}
	if hr.header.Get("X-Custom") != "val" {
		t.Errorf("header = %v", hr.header)
	}
}

func TestHTTPRecorder_Record_Success(t *testing.T) {
	var receivedBody []byte
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			t.Errorf("expected POST, got %s", r.Method)
		}
		if ct := r.Header.Get("Content-Type"); ct != "application/json" {
			t.Errorf("Content-Type = %s, want application/json", ct)
		}
		receivedBody, _ = io.ReadAll(r.Body)
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	r := HTTPRecorder(srv.URL)
	err := r.Record(context.Background(), []byte(`{"test": true}`))
	if err != nil {
		t.Fatal(err)
	}
	if string(receivedBody) != `{"test": true}` {
		t.Errorf("body = %s, want {\"test\": true}", string(receivedBody))
	}
}

func TestHTTPRecorder_Record_ServerError(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusInternalServerError)
	}))
	defer srv.Close()

	r := HTTPRecorder(srv.URL)
	err := r.Record(context.Background(), []byte("data"))
	if err == nil {
		t.Error("expected error on non-2xx response")
	}
}

func TestHTTPRecorder_Record_ContextCanceled(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		<-r.Context().Done()
	}))
	defer srv.Close()

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

	r := HTTPRecorder(srv.URL)
	err := r.Record(ctx, []byte("data"))
	if err == nil {
		t.Error("expected error on canceled context")
	}
}

func TestHTTPRecorder_Record_CustomHeader(t *testing.T) {
	var receivedHeader string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		receivedHeader = r.Header.Get("X-Custom")
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	r := HTTPRecorder(srv.URL, HeaderHTTPRecorderOption(http.Header{
		"X-Custom": []string{"my-value"},
	}))
	err := r.Record(context.Background(), []byte("data"))
	if err != nil {
		t.Fatal(err)
	}
	if receivedHeader != "my-value" {
		t.Errorf("X-Custom = %s, want my-value", receivedHeader)
	}
}

func TestHTTPRecorder_Record_CustomHeaderContentTypePreserved(t *testing.T) {
	var receivedCT string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		receivedCT = r.Header.Get("Content-Type")
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	r := HTTPRecorder(srv.URL, HeaderHTTPRecorderOption(http.Header{
		"Content-Type": []string{"text/plain"},
	}))
	err := r.Record(context.Background(), []byte("data"))
	if err != nil {
		t.Fatal(err)
	}
	if receivedCT != "text/plain" {
		t.Errorf("Content-Type = %s, want text/plain", receivedCT)
	}
}

func TestHTTPRecorder_Record_ContextTimeout(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-r.Context().Done():
			return
		case <-time.After(100 * time.Millisecond):
			w.WriteHeader(http.StatusOK)
		}
	}))
	defer srv.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
	defer cancel()

	r := HTTPRecorder(srv.URL)
	time.Sleep(20 * time.Millisecond) // ensure timeout has fired
	err := r.Record(ctx, []byte("data"))
	if err == nil {
		t.Error("expected error on deadline-exceeded context")
	}
}

func TestHTTPRecorder_NilHeader(t *testing.T) {
	r := HTTPRecorder("http://example.com")
	hr := r.(*httpRecorder)
	if hr.header != nil {
		t.Error("header should be nil by default")
	}
}

func TestHTTPRecorder_AddsHTTPScheme(t *testing.T) {
	r := HTTPRecorder("example.com:8080")
	hr := r.(*httpRecorder)
	if !strings.HasPrefix(hr.url, "http://") {
		t.Errorf("expected http:// prefix, got %s", hr.url)
	}
}

// --- TCPRecorder tests ---

func TestTCPRecorder_Record(t *testing.T) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	defer ln.Close()

	var received []byte
	done := make(chan struct{})
	go func() {
		conn, _ := ln.Accept()
		received, _ = io.ReadAll(conn)
		conn.Close()
		close(done)
	}()

	r := TCPRecorder(ln.Addr().String())
	err = r.Record(context.Background(), []byte("hello-tcp"))
	if err != nil {
		t.Fatal(err)
	}

	<-done
	if string(received) != "hello-tcp" {
		t.Errorf("received = %s, want hello-tcp", string(received))
	}
}

func TestTCPRecorder_Record_LargePayload(t *testing.T) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	defer ln.Close()

	var received []byte
	done := make(chan struct{})
	go func() {
		conn, _ := ln.Accept()
		received, _ = io.ReadAll(conn)
		conn.Close()
		close(done)
	}()

	// Write a payload larger than typical TCP buffer to exercise the write loop.
	payload := bytes.Repeat([]byte("x"), 64*1024)
	r := TCPRecorder(ln.Addr().String())
	err = r.Record(context.Background(), payload)
	if err != nil {
		t.Fatal(err)
	}

	<-done
	if len(received) != len(payload) {
		t.Errorf("received %d bytes, want %d", len(received), len(payload))
	}
	if !bytes.Equal(received, payload) {
		t.Error("received data does not match payload")
	}
}

func TestTCPRecorder_Record_ConnectionRefused(t *testing.T) {
	r := TCPRecorder("127.0.0.1:1", TimeoutTCPRecorderOption(50*time.Millisecond))
	err := r.Record(context.Background(), []byte("data"))
	if err == nil {
		t.Error("expected error on connection refused")
	}
}

func TestTCPRecorder_WithOptions(t *testing.T) {
	l := xlogger.Nop()
	r := TCPRecorder("127.0.0.1:8080",
		RecorderTCPRecorderOption("tcp-rec"),
		TimeoutTCPRecorderOption(3*time.Second),
		LogTCPRecorderOption(l),
	)
	tr := r.(*tcpRecorder)
	if tr.recorder != "tcp-rec" {
		t.Errorf("recorder = %s, want tcp-rec", tr.recorder)
	}
	if tr.dialer.Timeout != 3*time.Second {
		t.Errorf("timeout = %v, want 3s", tr.dialer.Timeout)
	}
}

func TestTCPRecorder_Record_ContextTimeout(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
	defer cancel()

	// Use a non-routable address to trigger a dial timeout.
	r := TCPRecorder("10.255.255.1:9999", TimeoutTCPRecorderOption(5*time.Second))
	err := r.Record(ctx, []byte("data"))
	if err == nil {
		t.Error("expected error on deadline-exceeded context")
	}
}

func TestTCPRecorder_WithLogger(t *testing.T) {
	l := xlogger.Nop()
	r := TCPRecorder("127.0.0.1:8080", LogTCPRecorderOption(l))
	tr := r.(*tcpRecorder)
	if tr.log != l {
		t.Error("log should be set")
	}
}

func TestTCPRecorder_NilLogger(t *testing.T) {
	r := TCPRecorder("127.0.0.1:0")
	tr := r.(*tcpRecorder)
	if tr.log != nil {
		t.Error("logger should be nil by default")
	}
}

// --- HandlerRecorderObject tests ---

func TestHandlerRecorderObject_Record(t *testing.T) {
	var buf bytes.Buffer
	fr := FileRecorder(&nopWriteCloser{&buf})

	p := &HandlerRecorderObject{
		Service: "test-svc",
		Network: "tcp",
		Host:    "example.com",
		Time:    time.Now(),
	}
	err := p.Record(context.Background(), fr)
	if err != nil {
		t.Fatal(err)
	}

	var decoded HandlerRecorderObject
	if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
		t.Fatal(err)
	}
	if decoded.Service != "test-svc" {
		t.Errorf("Service = %s, want test-svc", decoded.Service)
	}
	if decoded.Network != "tcp" {
		t.Errorf("Network = %s, want tcp", decoded.Network)
	}
}

func TestHandlerRecorderObject_Record_NilReceiver(t *testing.T) {
	var p *HandlerRecorderObject
	err := p.Record(context.Background(), &noopRecorder{})
	if err != nil {
		t.Errorf("nil receiver should not return error: %v", err)
	}
}

func TestHandlerRecorderObject_Record_NilRecorder(t *testing.T) {
	p := &HandlerRecorderObject{
		Service: "test",
		Time:    time.Now(),
	}
	err := p.Record(context.Background(), nil)
	if err != nil {
		t.Errorf("nil recorder should not return error: %v", err)
	}
}

func TestHandlerRecorderObject_Record_ZeroTime(t *testing.T) {
	p := &HandlerRecorderObject{
		Service: "test",
	}
	err := p.Record(context.Background(), &noopRecorder{})
	if err != nil {
		t.Errorf("zero time should return nil: %v", err)
	}
}

func TestHandlerRecorderObject_Record_FullObject(t *testing.T) {
	var buf bytes.Buffer
	fr := FileRecorder(&nopWriteCloser{&buf})

	now := time.Now()
	p := &HandlerRecorderObject{
		Node:        "node1",
		Service:     "svc",
		Network:     "tcp",
		RemoteAddr:  "1.2.3.4:5678",
		LocalAddr:   "10.0.0.1:8080",
		ClientAddr:  "5.6.7.8:1111",
		SrcAddr:     "5.6.7.8:1111",
		DstAddr:     "9.9.9.9:443",
		Host:        "example.com",
		Proto:       "https",
		ClientIP:    "5.6.7.8",
		ClientID:    "client-1",
		HTTP: &HTTPRecorderObject{
			Host:       "example.com",
			Method:     "GET",
			Proto:      "HTTP/1.1",
			Scheme:     "https",
			URI:        "/path",
			StatusCode: 200,
			Request: HTTPRequestRecorderObject{
				ContentLength: 100,
				Header:        http.Header{"Accept": []string{"*/*"}},
				Body:          []byte("req-body"),
			},
			Response: HTTPResponseRecorderObject{
				ContentLength: 50,
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
				Body:          []byte("resp-body"),
			},
		},
		Websocket: &WebsocketRecorderObject{
			From:    "client",
			OpCode:  1,
			Payload: []byte("ws-data"),
		},
		TLS: &TLSRecorderObject{
			ServerName:  "example.com",
			CipherSuite: "TLS_AES_128_GCM_SHA256",
			Version:     "1.3",
		},
		DNS: &DNSRecorderObject{
			ID:    42,
			Name:  "example.com",
			Type:  "A",
			Class: "IN",
		},
		Route:       "chain-1",
		InputBytes:  4096,
		OutputBytes: 8192,
		Redirect:    "http://other.example.com",
		Err:         "",
		SID:         "sid-123",
		Duration:    123 * time.Millisecond,
		Time:        now,
	}
	err := p.Record(context.Background(), fr)
	if err != nil {
		t.Fatal(err)
	}

	var decoded HandlerRecorderObject
	if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
		t.Fatal(err)
	}
	if decoded.Node != "node1" {
		t.Errorf("Node = %s, want node1", decoded.Node)
	}
	if decoded.InputBytes != 4096 {
		t.Errorf("InputBytes = %d, want 4096", decoded.InputBytes)
	}
	if decoded.OutputBytes != 8192 {
		t.Errorf("OutputBytes = %d, want 8192", decoded.OutputBytes)
	}
	if decoded.HTTP == nil {
		t.Fatal("HTTP should not be nil")
	}
	if decoded.HTTP.StatusCode != 200 {
		t.Errorf("HTTP status = %d, want 200", decoded.HTTP.StatusCode)
	}
	if decoded.HTTP.Request.Body == nil {
		t.Fatal("HTTP request body should not be nil")
	}
	if decoded.Websocket == nil {
		t.Fatal("Websocket should not be nil")
	}
	if decoded.TLS == nil {
		t.Fatal("TLS should not be nil")
	}
	if decoded.DNS == nil {
		t.Fatal("DNS should not be nil")
	}
	if decoded.Duration != 123*time.Millisecond {
		t.Errorf("Duration = %v, want 123ms", decoded.Duration)
	}
	if !decoded.Time.Equal(now) {
		t.Errorf("Time mismatch: %v != %v", decoded.Time, now)
	}
}

func TestHandlerRecorderObject_Record_WithError(t *testing.T) {
	var buf bytes.Buffer
	fr := FileRecorder(&nopWriteCloser{&buf})

	p := &HandlerRecorderObject{
		Service: "test",
		Time:    time.Now(),
		Err:     "connection reset",
	}
	err := p.Record(context.Background(), fr)
	if err != nil {
		t.Fatal(err)
	}

	var decoded HandlerRecorderObject
	if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
		t.Fatal(err)
	}
	if decoded.Err != "connection reset" {
		t.Errorf("Err = %s, want connection reset", decoded.Err)
	}
}

func TestHandlerRecorderObject_JSONRoundTrip(t *testing.T) {
	now := time.Now().Truncate(time.Millisecond)
	original := HandlerRecorderObject{
		Service:     "svc",
		Network:     "tcp",
		RemoteAddr:  "1.2.3.4:8080",
		LocalAddr:   "10.0.0.1:3128",
		ClientAddr:  "5.6.7.8:12345",
		SrcAddr:     "5.6.7.8:12345",
		DstAddr:     "93.184.216.34:443",
		Host:        "example.com",
		ClientIP:    "5.6.7.8",
		InputBytes:  1024,
		OutputBytes: 2048,
		SID:         "sid-1",
		Duration:    50 * time.Millisecond,
		Time:        now,
	}

	data, err := json.Marshal(original)
	if err != nil {
		t.Fatal(err)
	}

	var decoded HandlerRecorderObject
	if err := json.Unmarshal(data, &decoded); err != nil {
		t.Fatal(err)
	}

	if decoded.Service != original.Service {
		t.Errorf("Service = %s, want %s", decoded.Service, original.Service)
	}
	if decoded.InputBytes != original.InputBytes {
		t.Errorf("InputBytes = %d, want %d", decoded.InputBytes, original.InputBytes)
	}
	if decoded.OutputBytes != original.OutputBytes {
		t.Errorf("OutputBytes = %d, want %d", decoded.OutputBytes, original.OutputBytes)
	}
	if decoded.SID != original.SID {
		t.Errorf("SID = %s, want %s", decoded.SID, original.SID)
	}
	if !decoded.Time.Equal(now) {
		t.Errorf("Time = %v, want %v", decoded.Time, now)
	}
}

func TestHandlerRecorderObject_JSON_OmitEmpty(t *testing.T) {
	p := HandlerRecorderObject{
		Service: "svc",
		Time:    time.Now(),
	}
	data, err := json.Marshal(p)
	if err != nil {
		t.Fatal(err)
	}
	// Fields with omitempty should not appear.
	if strings.Contains(string(data), `"node"`) {
		t.Error("empty node should be omitted")
	}
	if strings.Contains(string(data), `"err"`) {
		t.Error("empty err should be omitted")
	}
	if strings.Contains(string(data), `"route"`) {
		t.Error("empty route should be omitted")
	}
}

// --- Interface compliance ---

func TestFileRecorder_SatisfiesRecorder(t *testing.T) {
	var _ recorder.Recorder = FileRecorder(&nopWriteCloser{&bytes.Buffer{}})
}

func TestHTTPRecorder_SatisfiesRecorder(t *testing.T) {
	var _ recorder.Recorder = HTTPRecorder("http://example.com")
}

func TestTCPRecorder_SatisfiesRecorder(t *testing.T) {
	var _ recorder.Recorder = TCPRecorder("127.0.0.1:8080")
}

var _ io.Closer = (*fileRecorder)(nil)
var _ io.Closer = (*redisSetRecorder)(nil)
var _ io.Closer = (*redisListRecorder)(nil)
var _ io.Closer = (*redisSortedSetRecorder)(nil)

// --- Helper types ---

type nopWriteCloser struct {
	io.Writer
}

func (n *nopWriteCloser) Close() error {
	return nil
}

type noopRecorder struct{}

func (n *noopRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error {
	return nil
}

type errorWriter struct{}

func (e *errorWriter) Write(b []byte) (int, error) {
	return 0, errors.New("write error")
}

func (e *errorWriter) Close() error {
	return nil
}

type writeOnceThenError struct {
	max   int
	count int
}

func (w *writeOnceThenError) Write(b []byte) (int, error) {
	w.count++
	if w.count > w.max {
		return 0, errors.New("write error")
	}
	return len(b), nil
}

func (w *writeOnceThenError) Close() error {
	return nil
}
