package protosocket

import (
	"context"
	"encoding/base64"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	toki "git.toki-labs.com/toki/proto-socket/go"
	"github.com/jackc/pgx/v5"
	"google.golang.org/protobuf/types/known/structpb"
	"nhooyr.io/websocket"

	apphttp "github.com/nomadcode/nomadcode-core/internal/http"
	"github.com/nomadcode/nomadcode-core/internal/notification"
	"github.com/nomadcode/nomadcode-core/internal/storage"
	"github.com/nomadcode/nomadcode-core/internal/workflow"
)

type fakeTaskService struct {
	created   workflow.CreateTaskInput
	createOut storage.Task
	createErr error

	listLimit int32
	listOut   []storage.Task
	listErr   error

	getID  string
	getOut storage.Task
	getErr error

	enqueueID  string
	enqueueOut storage.Task
	enqueueErr error
}

func (f *fakeTaskService) CreateTask(_ context.Context, in workflow.CreateTaskInput) (storage.Task, error) {
	f.created = in
	return f.createOut, f.createErr
}

func (f *fakeTaskService) ListTasks(_ context.Context, limit int32) ([]storage.Task, error) {
	f.listLimit = limit
	return f.listOut, f.listErr
}

func (f *fakeTaskService) GetTask(_ context.Context, id string) (storage.Task, error) {
	f.getID = id
	return f.getOut, f.getErr
}

func (f *fakeTaskService) EnqueueTask(_ context.Context, id string) (storage.Task, error) {
	f.enqueueID = id
	return f.enqueueOut, f.enqueueErr
}

func dispatchTo(fake TaskService, req Envelope) Envelope {
	d := NewDispatcher()
	NewTaskChannels(fake).Register(d)
	return d.Dispatch(context.Background(), req)
}

func TestTaskCreateActionCallsWorkflowAndReturnsResponse(t *testing.T) {
	fake := &fakeTaskService{
		createOut: storage.Task{ID: "task-1", Status: "pending", Title: "My Task", Source: "client"},
	}

	res := dispatchTo(fake, Envelope{
		ID:      "r1",
		Type:    "request",
		Channel: "task",
		Action:  "task.create",
		Payload: map[string]any{
			"title":   "My Task",
			"source":  "client",
			"payload": map[string]any{},
		},
	})

	if fake.created.Title != "My Task" || fake.created.Source != "client" {
		t.Fatalf("workflow not called with expected input: %+v", fake.created)
	}
	if res.Type != "response" {
		t.Fatalf("expected response type, got %q (err=%+v)", res.Type, res.Error)
	}
	if res.CorrelationID != "r1" {
		t.Errorf("expected correlation_id r1, got %q", res.CorrelationID)
	}
	if res.Payload["id"] != "task-1" || res.Payload["status"] != "pending" {
		t.Errorf("unexpected response payload: %+v", res.Payload)
	}
	if _, ok := res.Payload["task"].(map[string]any); !ok {
		t.Errorf("expected nested task map in response payload: %+v", res.Payload)
	}
}

func TestTaskListActionForwardsLimitToService(t *testing.T) {
	fake := &fakeTaskService{
		listOut: []storage.Task{{ID: "task-1"}, {ID: "task-2"}},
	}

	res := dispatchTo(fake, Envelope{
		ID:      "r2",
		Type:    "request",
		Channel: "task",
		Action:  "task.list",
		Payload: map[string]any{"limit": float64(5)},
	})

	if fake.listLimit != 5 {
		t.Errorf("expected limit 5 forwarded to service, got %d", fake.listLimit)
	}
	if res.Type != "response" {
		t.Fatalf("expected response type, got %q", res.Type)
	}
	tasks, ok := res.Payload["tasks"].([]any)
	if !ok || len(tasks) != 2 {
		t.Errorf("expected 2 tasks in payload, got %+v", res.Payload["tasks"])
	}

	// Omitted limit forwards 0; the workflow service owns default/bounding.
	resDefault := dispatchTo(fake, Envelope{ID: "r2b", Action: "task.list", Channel: "task", Payload: map[string]any{}})
	if fake.listLimit != 0 {
		t.Errorf("expected omitted limit to forward 0, got %d", fake.listLimit)
	}
	if resDefault.Type != "response" {
		t.Errorf("expected response type for default limit, got %q", resDefault.Type)
	}
}

func TestTaskGetActionMapsNotFoundError(t *testing.T) {
	fake := &fakeTaskService{getErr: pgx.ErrNoRows}

	res := dispatchTo(fake, Envelope{
		ID:      "r3",
		Action:  "task.get",
		Channel: "task",
		Payload: map[string]any{"id": "missing"},
	})

	if fake.getID != "missing" {
		t.Errorf("expected get called with 'missing', got %q", fake.getID)
	}
	if res.Type != "error" {
		t.Fatalf("expected error type, got %q", res.Type)
	}
	if res.Error == nil || res.Error.Code != "task.not_found" {
		t.Errorf("expected task.not_found, got %+v", res.Error)
	}
	if res.Error.Retryable {
		t.Error("expected not_found to be non-retryable")
	}
}

func TestTaskEnqueueActionMapsConflictError(t *testing.T) {
	fake := &fakeTaskService{enqueueErr: workflow.ErrTaskCannotBeEnqueued}

	res := dispatchTo(fake, Envelope{
		ID:      "r4",
		Action:  "task.enqueue",
		Channel: "task",
		Payload: map[string]any{"id": "task-9"},
	})

	if res.Type != "error" {
		t.Fatalf("expected error type, got %q", res.Type)
	}
	if res.Error == nil || res.Error.Code != "task.conflict" {
		t.Errorf("expected task.conflict, got %+v", res.Error)
	}
}

func TestTaskActionRejectsInvalidPayload(t *testing.T) {
	fake := &fakeTaskService{}

	res := dispatchTo(fake, Envelope{
		ID:      "r5",
		Action:  "task.get",
		Channel: "task",
		Payload: map[string]any{"id": float64(123)}, // wrong type for string id
	})

	if res.Type != "error" {
		t.Fatalf("expected error type, got %q", res.Type)
	}
	if res.Error == nil || res.Error.Code != "task.invalid_payload" {
		t.Errorf("expected task.invalid_payload, got %+v", res.Error)
	}
	if fake.getID != "" {
		t.Errorf("service should not be called on invalid payload, got %q", fake.getID)
	}
}

type captureBroadcaster struct {
	env Envelope
	err error
}

func (c *captureBroadcaster) BroadcastEnvelope(_ context.Context, env Envelope) error {
	c.env = env
	return c.err
}

func TestHandleTaskEventBroadcastsStatusChangedEnvelope(t *testing.T) {
	cap := &captureBroadcaster{}
	sink := NewTaskEventBroadcaster(cap)

	now := time.Now().UTC()
	err := sink.HandleTaskEvent(context.Background(), notification.TaskEvent{
		Type:       notification.TaskEventRunning,
		TaskID:     "task-7",
		Title:      "Run me",
		Status:     "running",
		Message:    "started",
		Attempt:    2,
		Reason:     "retry-on-failure",
		OccurredAt: now,
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if cap.env.Type != "event" || cap.env.Channel != "task" || cap.env.Action != "task.status.changed" {
		t.Fatalf("unexpected envelope: type=%q channel=%q action=%q", cap.env.Type, cap.env.Channel, cap.env.Action)
	}
	if cap.env.Payload["id"] != "task-7" || cap.env.Payload["status"] != "running" {
		t.Errorf("unexpected event payload: %+v", cap.env.Payload)
	}
	if cap.env.Payload["type"] != string(notification.TaskEventRunning) {
		t.Errorf("expected type %q, got %v", notification.TaskEventRunning, cap.env.Payload["type"])
	}
	if cap.env.Payload["attempt"] != 2 {
		t.Errorf("expected attempt 2, got %v", cap.env.Payload["attempt"])
	}
	if cap.env.Payload["reason"] != "retry-on-failure" {
		t.Errorf("expected reason 'retry-on-failure', got %v", cap.env.Payload["reason"])
	}
	if cap.env.Payload["occurred_at"] != now.Format(time.RFC3339Nano) {
		t.Errorf("expected occurred_at %q, got %v", now.Format(time.RFC3339Nano), cap.env.Payload["occurred_at"])
	}

	// Test omission of empty reason and zero occurred_at
	err = sink.HandleTaskEvent(context.Background(), notification.TaskEvent{
		Type:    notification.TaskEventRunning,
		TaskID:  "task-8",
		Title:   "Run me again",
		Status:  "running",
		Message: "started",
		Attempt: 1,
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if _, ok := cap.env.Payload["reason"]; ok {
		t.Error("expected empty reason to be omitted from payload, but it was present")
	}
	if _, ok := cap.env.Payload["occurred_at"]; ok {
		t.Error("expected zero occurred_at to be omitted from payload, but it was present")
	}
}

// --- route-level contract tests over the real router + proto-socket server ---

func newContractRouter(t *testing.T, fake TaskService) (*httptest.Server, func()) {
	t.Helper()
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	srv := NewServer(Config{HeartbeatIntervalSec: 0, HeartbeatWaitSec: 0}, logger)
	NewTaskChannels(fake).Register(srv.Dispatcher())

	router := apphttp.NewRouter(
		apphttp.NewHandler(nil, nil, nil, logger),
		logger,
		apphttp.AuthConfig{Username: "nomadcode", Password: "secret"},
		srv,
		"/proto-socket",
	)
	ts := httptest.NewServer(router)
	return ts, func() {
		ts.Close()
		_ = srv.Close()
	}
}

func wsURL(ts *httptest.Server) string {
	return strings.Replace(ts.URL, "http", "ws", 1) + "/proto-socket"
}

func authDialOptions() *websocket.DialOptions {
	header := http.Header{}
	header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("nomadcode:secret")))
	return &websocket.DialOptions{HTTPHeader: header}
}

func TestRouterProtoSocketTaskListRequiresAuth(t *testing.T) {
	fake := &fakeTaskService{listOut: []storage.Task{{ID: "task-1"}}}
	ts, cleanup := newContractRouter(t, fake)
	defer cleanup()

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

	// Unauthenticated dial must fail at the route.
	if conn, _, err := websocket.Dial(ctx, wsURL(ts), nil); err == nil {
		conn.Close(websocket.StatusNormalClosure, "")
		t.Fatal("expected unauthenticated websocket dial to fail")
	}

	// Authenticated dial succeeds and task.list returns a response envelope.
	conn, _, err := websocket.Dial(ctx, wsURL(ts), authDialOptions())
	if err != nil {
		t.Fatalf("authenticated dial failed: %v", err)
	}
	defer conn.Close(websocket.StatusNormalClosure, "")

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

	reqStruct, err := Envelope{
		ProtocolVersion: ProtocolVersion,
		ID:              "req-list",
		Type:            "request",
		Channel:         "task",
		Action:          "task.list",
		Payload:         map[string]any{"limit": float64(20)},
	}.ToStruct()
	if err != nil {
		t.Fatalf("failed to build request: %v", err)
	}

	resStruct, err := toki.SendRequestTyped[*structpb.Struct, *structpb.Struct](&client.Communicator, reqStruct, 2*time.Second)
	if err != nil {
		t.Fatalf("send request failed: %v", err)
	}
	res, err := EnvelopeFromStruct(resStruct)
	if err != nil {
		t.Fatalf("parse response failed: %v", err)
	}
	if res.Type != "response" || res.CorrelationID != "req-list" {
		t.Fatalf("unexpected response: type=%q correlation=%q", res.Type, res.CorrelationID)
	}
	if _, ok := res.Payload["tasks"]; !ok {
		t.Errorf("expected tasks payload, got %+v", res.Payload)
	}
	requireDiagnosticsMeta(t, res, "task", "task.list", "")
}

func TestProtoSocketTaskActionsMatchRestSemantics(t *testing.T) {
	fake := &fakeTaskService{
		createOut:  storage.Task{ID: "task-c", Status: "pending"},
		getOut:     storage.Task{ID: "task-g", Status: "running"},
		enqueueOut: storage.Task{ID: "task-e", Status: "queued"},
	}
	ts, cleanup := newContractRouter(t, fake)
	defer cleanup()

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	conn, _, err := websocket.Dial(ctx, wsURL(ts), authDialOptions())
	if err != nil {
		t.Fatalf("dial failed: %v", err)
	}
	defer conn.Close(websocket.StatusNormalClosure, "")
	client := toki.NewWsClient(conn, 0, 0, ParserMap())
	defer client.Close()

	send := func(action string, payload map[string]any) Envelope {
		reqStruct, err := Envelope{
			ProtocolVersion: ProtocolVersion,
			ID:              "req-" + action,
			Type:            "request",
			Channel:         "task",
			Action:          action,
			Payload:         payload,
		}.ToStruct()
		if err != nil {
			t.Fatalf("build request %s: %v", action, err)
		}
		resStruct, err := toki.SendRequestTyped[*structpb.Struct, *structpb.Struct](&client.Communicator, reqStruct, 2*time.Second)
		if err != nil {
			t.Fatalf("send %s: %v", action, err)
		}
		res, err := EnvelopeFromStruct(resStruct)
		if err != nil {
			t.Fatalf("parse %s: %v", action, err)
		}
		return res
	}

	create := send("task.create", map[string]any{"title": "T", "source": "client", "payload": map[string]any{}})
	if create.Type != "response" || create.Payload["id"] != "task-c" || create.Payload["status"] != "pending" {
		t.Errorf("unexpected create response: %+v (err=%+v)", create.Payload, create.Error)
	}
	requireDiagnosticsMeta(t, create, "task", "task.create", "")

	get := send("task.get", map[string]any{"id": "task-g"})
	if get.Type != "response" {
		t.Errorf("unexpected get response type: %q", get.Type)
	}
	if task, ok := get.Payload["task"].(map[string]any); !ok || task["status"] != "running" {
		t.Errorf("unexpected get task payload: %+v", get.Payload)
	}
	requireDiagnosticsMeta(t, get, "task", "task.get", "")

	enqueue := send("task.enqueue", map[string]any{"id": "task-e"})
	if enqueue.Type != "response" || enqueue.Payload["status"] != "queued" {
		t.Errorf("unexpected enqueue response: %+v", enqueue.Payload)
	}
	requireDiagnosticsMeta(t, enqueue, "task", "task.enqueue", "")
}

func TestProtoSocketTaskErrorEnvelopeCodes(t *testing.T) {
	fake := &fakeTaskService{
		getErr:     pgx.ErrNoRows,
		enqueueErr: workflow.ErrTaskCannotBeEnqueued,
		createErr:  workflow.ErrInvalidTaskInput,
	}
	ts, cleanup := newContractRouter(t, fake)
	defer cleanup()

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	conn, _, err := websocket.Dial(ctx, wsURL(ts), authDialOptions())
	if err != nil {
		t.Fatalf("dial failed: %v", err)
	}
	defer conn.Close(websocket.StatusNormalClosure, "")
	client := toki.NewWsClient(conn, 0, 0, ParserMap())
	defer client.Close()

	send := func(action string, payload map[string]any) Envelope {
		reqStruct, _ := Envelope{ProtocolVersion: ProtocolVersion, ID: "e-" + action, Type: "request", Channel: "task", Action: action, Payload: payload}.ToStruct()
		resStruct, err := toki.SendRequestTyped[*structpb.Struct, *structpb.Struct](&client.Communicator, reqStruct, 2*time.Second)
		if err != nil {
			t.Fatalf("send %s: %v", action, err)
		}
		res, _ := EnvelopeFromStruct(resStruct)
		return res
	}

	cases := []struct {
		action  string
		payload map[string]any
		code    string
	}{
		{"task.get", map[string]any{"id": "x"}, "task.not_found"},
		{"task.enqueue", map[string]any{"id": "x"}, "task.conflict"},
		{"task.create", map[string]any{"title": "T", "source": "client"}, "task.invalid_input"},
		{"task.get", map[string]any{"id": float64(1)}, "task.invalid_payload"},
		{"task.unknown", map[string]any{}, "UNSUPPORTED_ACTION"},
	}
	for _, tc := range cases {
		res := send(tc.action, tc.payload)
		if res.Type != "error" {
			t.Errorf("%s: expected error type, got %q", tc.action, res.Type)
			continue
		}
		if res.Error == nil || res.Error.Code != tc.code {
			t.Errorf("%s: expected code %q, got %+v", tc.action, tc.code, res.Error)
		}
		requireDiagnosticsMeta(t, res, "task", tc.action, tc.code)
	}
}

func TestProtoSocketServerUsesConfiguredHeartbeat(t *testing.T) {
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	srv := NewServer(Config{HeartbeatIntervalSec: 1, HeartbeatWaitSec: 1}, logger)
	defer srv.Close()

	ts := httptest.NewServer(srv)
	defer ts.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	conn, _, err := websocket.Dial(ctx, strings.Replace(ts.URL, "http", "ws", 1), nil)
	if err != nil {
		t.Fatalf("dial failed: %v", err)
	}
	conn.Close(websocket.StatusNormalClosure, "")
}
