package backendclient_test

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

	"galaxy/gateway/internal/backendclient"
	"galaxy/gateway/internal/downstream"
	lobbymodel "galaxy/model/lobby"
	"galaxy/transcoder"

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

func newAuthCommand(t *testing.T, messageType string, payload []byte) downstream.AuthenticatedCommand {
	t.Helper()
	return downstream.AuthenticatedCommand{
		MessageType:  messageType,
		PayloadBytes: payload,
		UserID:       "user-1",
	}
}

func mustEncode[T any](t *testing.T, encode func(*T) ([]byte, error), value *T) []byte {
	t.Helper()
	bytes, err := encode(value)
	require.NoError(t, err)
	return bytes
}

func TestExecuteLobbyMyGamesListReturnsItems(t *testing.T) {
	t.Parallel()

	enrollment := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
	created := time.Date(2026, 5, 7, 10, 0, 0, 0, time.UTC)

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, "/api/v1/user/lobby/my/games", r.URL.Path)
		require.Equal(t, "user-1", r.Header.Get(backendclient.HeaderUserID))
		writeJSON(t, w, http.StatusOK, map[string]any{
			"items": []map[string]any{{
				"game_id":            "game-1",
				"game_name":          "Test Game",
				"game_type":          "private",
				"status":             "draft",
				"owner_user_id":      "user-1",
				"min_players":        2,
				"max_players":        8,
				"enrollment_ends_at": enrollment.Format(time.RFC3339Nano),
				"created_at":         created.Format(time.RFC3339Nano),
				"updated_at":         created.Format(time.RFC3339Nano),
			}},
		})
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.MyGamesListRequestToPayload, &lobbymodel.MyGamesListRequest{})
	result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeMyGamesList, payload))
	require.NoError(t, err)
	assert.Equal(t, "ok", result.ResultCode)

	decoded, err := transcoder.PayloadToMyGamesListResponse(result.PayloadBytes)
	require.NoError(t, err)
	require.Len(t, decoded.Items, 1)
	assert.Equal(t, "game-1", decoded.Items[0].GameID)
	assert.Equal(t, enrollment, decoded.Items[0].EnrollmentEndsAt)
}

func TestExecuteLobbyPublicGamesListPaginatesAndDecodes(t *testing.T) {
	t.Parallel()

	enrollment := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
	created := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, "/api/v1/user/lobby/games", r.URL.Path)
		require.Equal(t, "2", r.URL.Query().Get("page"))
		require.Equal(t, "10", r.URL.Query().Get("page_size"))
		writeJSON(t, w, http.StatusOK, map[string]any{
			"items": []map[string]any{{
				"game_id":            "public-1",
				"game_name":          "Open",
				"game_type":          "public",
				"status":             "enrollment_open",
				"owner_user_id":      nil,
				"min_players":        4,
				"max_players":        12,
				"enrollment_ends_at": enrollment.Format(time.RFC3339Nano),
				"created_at":         created.Format(time.RFC3339Nano),
				"updated_at":         created.Format(time.RFC3339Nano),
			}},
			"page":      2,
			"page_size": 10,
			"total":     31,
		})
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.PublicGamesListRequestToPayload, &lobbymodel.PublicGamesListRequest{Page: 2, PageSize: 10})
	result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypePublicGamesList, payload))
	require.NoError(t, err)
	assert.Equal(t, "ok", result.ResultCode)

	decoded, err := transcoder.PayloadToPublicGamesListResponse(result.PayloadBytes)
	require.NoError(t, err)
	assert.Equal(t, 2, decoded.Page)
	assert.Equal(t, 10, decoded.PageSize)
	assert.Equal(t, 31, decoded.Total)
	require.Len(t, decoded.Items, 1)
	assert.Empty(t, decoded.Items[0].OwnerUserID)
}

func TestExecuteLobbyMyApplicationsList(t *testing.T) {
	t.Parallel()

	created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
	decided := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/api/v1/user/lobby/my/applications", r.URL.Path)
		writeJSON(t, w, http.StatusOK, map[string]any{
			"items": []map[string]any{
				{
					"application_id":    "app-1",
					"game_id":           "public-1",
					"applicant_user_id": "user-1",
					"race_name":         "Vegan Federation",
					"status":            "pending",
					"created_at":        created.Format(time.RFC3339Nano),
				},
				{
					"application_id":    "app-2",
					"game_id":           "public-2",
					"applicant_user_id": "user-1",
					"race_name":         "Lithic Compact",
					"status":            "approved",
					"created_at":        created.Format(time.RFC3339Nano),
					"decided_at":        decided.Format(time.RFC3339Nano),
				},
			},
		})
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.MyApplicationsListRequestToPayload, &lobbymodel.MyApplicationsListRequest{})
	result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeMyApplicationsList, payload))
	require.NoError(t, err)
	assert.Equal(t, "ok", result.ResultCode)

	decoded, err := transcoder.PayloadToMyApplicationsListResponse(result.PayloadBytes)
	require.NoError(t, err)
	require.Len(t, decoded.Items, 2)
	assert.Equal(t, "pending", decoded.Items[0].Status)
	assert.Nil(t, decoded.Items[0].DecidedAt)
	require.NotNil(t, decoded.Items[1].DecidedAt)
	assert.Equal(t, decided, *decoded.Items[1].DecidedAt)
}

func TestExecuteLobbyMyInvitesList(t *testing.T) {
	t.Parallel()

	created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
	expires := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC)

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/api/v1/user/lobby/my/invites", r.URL.Path)
		writeJSON(t, w, http.StatusOK, map[string]any{
			"items": []map[string]any{{
				"invite_id":       "invite-1",
				"game_id":         "private-1",
				"inviter_user_id": "user-host",
				"invited_user_id": "user-1",
				"race_name":       "Vegan Federation",
				"status":          "pending",
				"created_at":      created.Format(time.RFC3339Nano),
				"expires_at":      expires.Format(time.RFC3339Nano),
			}},
		})
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.MyInvitesListRequestToPayload, &lobbymodel.MyInvitesListRequest{})
	result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeMyInvitesList, payload))
	require.NoError(t, err)
	assert.Equal(t, "ok", result.ResultCode)

	decoded, err := transcoder.PayloadToMyInvitesListResponse(result.PayloadBytes)
	require.NoError(t, err)
	require.Len(t, decoded.Items, 1)
	assert.Equal(t, "user-1", decoded.Items[0].InvitedUserID)
	assert.Empty(t, decoded.Items[0].Code)
	assert.Equal(t, expires, decoded.Items[0].ExpiresAt)
}

func TestExecuteLobbyGameCreatePostsPrivateAndProjectsToSummary(t *testing.T) {
	t.Parallel()

	enrollment := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
	created := time.Date(2026, 5, 7, 10, 0, 0, 0, time.UTC)

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/api/v1/user/lobby/games", r.URL.Path)

		var body map[string]any
		raw, err := io.ReadAll(r.Body)
		require.NoError(t, err)
		require.NoError(t, json.Unmarshal(raw, &body))
		assert.Equal(t, "private", body["visibility"])
		assert.Equal(t, "First Contact", body["game_name"])
		assert.Equal(t, "0 0 * * *", body["turn_schedule"])

		// Backend always returns the full GameDetail including runtime
		// snapshot fields the gateway must tolerate.
		writeJSON(t, w, http.StatusCreated, map[string]any{
			"game_id":               "newly-created",
			"game_name":             "First Contact",
			"game_type":             "private",
			"status":                "draft",
			"owner_user_id":         "user-1",
			"min_players":           2,
			"max_players":           8,
			"enrollment_ends_at":    enrollment.Format(time.RFC3339Nano),
			"created_at":            created.Format(time.RFC3339Nano),
			"updated_at":            created.Format(time.RFC3339Nano),
			"visibility":            "private",
			"description":           "",
			"turn_schedule":         "0 0 * * *",
			"target_engine_version": "v1",
			"start_gap_hours":       24,
			"start_gap_players":     2,
			"current_turn":          0,
			"runtime_status":        "",
		})
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.GameCreateRequestToPayload, &lobbymodel.GameCreateRequest{
		GameName:            "First Contact",
		Description:         "",
		MinPlayers:          2,
		MaxPlayers:          8,
		StartGapHours:       24,
		StartGapPlayers:     2,
		EnrollmentEndsAt:    enrollment,
		TurnSchedule:        "0 0 * * *",
		TargetEngineVersion: "v1",
	})
	result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeGameCreate, payload))
	require.NoError(t, err)
	assert.Equal(t, "ok", result.ResultCode)

	decoded, err := transcoder.PayloadToGameCreateResponse(result.PayloadBytes)
	require.NoError(t, err)
	assert.Equal(t, "newly-created", decoded.Game.GameID)
	assert.Equal(t, "draft", decoded.Game.Status)
}

func TestExecuteLobbyGameCreateRejectsEmptyGameName(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		t.Errorf("backend must not be hit on validation failure")
		w.WriteHeader(http.StatusInternalServerError)
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.GameCreateRequestToPayload, &lobbymodel.GameCreateRequest{
		MinPlayers:          2,
		MaxPlayers:          8,
		EnrollmentEndsAt:    time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
		TurnSchedule:        "0 0 * * *",
		TargetEngineVersion: "v1",
	})
	_, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeGameCreate, payload))
	require.Error(t, err)
	assert.Contains(t, err.Error(), "game_name must not be empty")
}

func TestExecuteLobbyApplicationSubmitPostsRaceName(t *testing.T) {
	t.Parallel()

	created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/api/v1/user/lobby/games/public-1/applications", r.URL.Path)

		var body map[string]any
		raw, err := io.ReadAll(r.Body)
		require.NoError(t, err)
		require.NoError(t, json.Unmarshal(raw, &body))
		assert.Equal(t, "Vegan Federation", body["race_name"])

		writeJSON(t, w, http.StatusCreated, map[string]any{
			"application_id":    "app-3",
			"game_id":           "public-1",
			"applicant_user_id": "user-1",
			"race_name":         "Vegan Federation",
			"status":            "pending",
			"created_at":        created.Format(time.RFC3339Nano),
		})
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.ApplicationSubmitRequestToPayload, &lobbymodel.ApplicationSubmitRequest{
		GameID:   "public-1",
		RaceName: "Vegan Federation",
	})
	result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeApplicationSubmit, payload))
	require.NoError(t, err)
	assert.Equal(t, "ok", result.ResultCode)

	decoded, err := transcoder.PayloadToApplicationSubmitResponse(result.PayloadBytes)
	require.NoError(t, err)
	assert.Equal(t, "app-3", decoded.Application.ApplicationID)
	assert.Equal(t, "pending", decoded.Application.Status)
}

func TestExecuteLobbyInviteRedeemPostsToBackend(t *testing.T) {
	t.Parallel()

	created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
	expires := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC)
	decided := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/api/v1/user/lobby/games/private-1/invites/invite-1/redeem", r.URL.Path)
		writeJSON(t, w, http.StatusOK, map[string]any{
			"invite_id":       "invite-1",
			"game_id":         "private-1",
			"inviter_user_id": "user-host",
			"invited_user_id": "user-1",
			"race_name":       "Vegan Federation",
			"status":          "accepted",
			"created_at":      created.Format(time.RFC3339Nano),
			"expires_at":      expires.Format(time.RFC3339Nano),
			"decided_at":      decided.Format(time.RFC3339Nano),
		})
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.InviteRedeemRequestToPayload, &lobbymodel.InviteRedeemRequest{GameID: "private-1", InviteID: "invite-1"})
	result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeInviteRedeem, payload))
	require.NoError(t, err)
	assert.Equal(t, "ok", result.ResultCode)

	decoded, err := transcoder.PayloadToInviteRedeemResponse(result.PayloadBytes)
	require.NoError(t, err)
	assert.Equal(t, "accepted", decoded.Invite.Status)
}

func TestExecuteLobbyInviteDeclinePostsToBackend(t *testing.T) {
	t.Parallel()

	created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
	expires := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC)
	decided := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/api/v1/user/lobby/games/private-1/invites/invite-1/decline", r.URL.Path)
		writeJSON(t, w, http.StatusOK, map[string]any{
			"invite_id":       "invite-1",
			"game_id":         "private-1",
			"inviter_user_id": "user-host",
			"invited_user_id": "user-1",
			"race_name":       "Vegan Federation",
			"status":          "declined",
			"created_at":      created.Format(time.RFC3339Nano),
			"expires_at":      expires.Format(time.RFC3339Nano),
			"decided_at":      decided.Format(time.RFC3339Nano),
		})
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.InviteDeclineRequestToPayload, &lobbymodel.InviteDeclineRequest{GameID: "private-1", InviteID: "invite-1"})
	result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeInviteDecline, payload))
	require.NoError(t, err)
	assert.Equal(t, "ok", result.ResultCode)

	decoded, err := transcoder.PayloadToInviteDeclineResponse(result.PayloadBytes)
	require.NoError(t, err)
	assert.Equal(t, "declined", decoded.Invite.Status)
}

func TestExecuteLobbyProjectsBackendErrorAcrossCommands(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name        string
		messageType string
		payload     []byte
		statusCode  int
		want        string
	}{
		{
			name:        "public games conflict",
			messageType: lobbymodel.MessageTypePublicGamesList,
			payload:     mustEncode(t, transcoder.PublicGamesListRequestToPayload, &lobbymodel.PublicGamesListRequest{Page: 1, PageSize: 50}),
			statusCode:  http.StatusConflict,
			want:        "conflict",
		},
		{
			name:        "applications forbidden",
			messageType: lobbymodel.MessageTypeApplicationSubmit,
			payload:     mustEncode(t, transcoder.ApplicationSubmitRequestToPayload, &lobbymodel.ApplicationSubmitRequest{GameID: "g", RaceName: "r"}),
			statusCode:  http.StatusForbidden,
			want:        "forbidden",
		},
		{
			name:        "invite redeem not found",
			messageType: lobbymodel.MessageTypeInviteRedeem,
			payload:     mustEncode(t, transcoder.InviteRedeemRequestToPayload, &lobbymodel.InviteRedeemRequest{GameID: "g", InviteID: "i"}),
			statusCode:  http.StatusNotFound,
			want:        "subject_not_found",
		},
		{
			name:        "create invalid request",
			messageType: lobbymodel.MessageTypeGameCreate,
			payload:     mustEncode(t, transcoder.GameCreateRequestToPayload, validCreateRequest()),
			statusCode:  http.StatusBadRequest,
			want:        "invalid_request",
		},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				writeJSON(t, w, tc.statusCode, map[string]any{
					"error": map[string]any{"code": tc.want, "message": "from backend"},
				})
			}))
			t.Cleanup(server.Close)

			client := newRESTClient(t, server)
			result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, tc.messageType, tc.payload))
			require.NoError(t, err)
			assert.Equal(t, tc.want, result.ResultCode)

			errResp, err := transcoder.PayloadToLobbyErrorResponse(result.PayloadBytes)
			require.NoError(t, err)
			assert.Equal(t, tc.want, errResp.Error.Code)
			assert.Equal(t, "from backend", errResp.Error.Message)
		})
	}
}

func TestExecuteLobbyMapsServiceUnavailableToDownstreamError(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusServiceUnavailable)
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	payload := mustEncode(t, transcoder.MyGamesListRequestToPayload, &lobbymodel.MyGamesListRequest{})
	_, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeMyGamesList, payload))
	require.Error(t, err)
	assert.True(t, errors.Is(err, downstream.ErrDownstreamUnavailable))
}

func TestExecuteLobbyRejectsUnknownMessageType(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(server.Close)

	client := newRESTClient(t, server)
	_, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, "lobby.unknown", []byte{0x01}))
	require.Error(t, err)
	assert.True(t, strings.Contains(err.Error(), "unsupported message type"))
}

func validCreateRequest() *lobbymodel.GameCreateRequest {
	return &lobbymodel.GameCreateRequest{
		GameName:            "Test",
		Description:         "",
		MinPlayers:          2,
		MaxPlayers:          8,
		StartGapHours:       24,
		StartGapPlayers:     2,
		EnrollmentEndsAt:    time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
		TurnSchedule:        "0 0 * * *",
		TargetEngineVersion: "v1",
	}
}
