package game

import (
	"context"
	"net/http"
	"path/filepath"
	"runtime"
	"slices"
	"testing"

	"github.com/getkin/kin-openapi/openapi3"
	"github.com/stretchr/testify/require"
)

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

	loadOpenAPISpec(t)
}

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

	doc := loadOpenAPISpec(t)

	tests := []struct {
		name    string
		path    string
		method  string
		status  int
		wantRef string
	}{
		{
			name:    "admin get game status",
			path:    "/api/v1/admin/status",
			method:  http.MethodGet,
			status:  http.StatusOK,
			wantRef: "#/components/schemas/StateResponse",
		},
		{
			name:    "admin init game",
			path:    "/api/v1/admin/init",
			method:  http.MethodPost,
			status:  http.StatusCreated,
			wantRef: "#/components/schemas/StateResponse",
		},
		{
			name:    "get report",
			path:    "/api/v1/report",
			method:  http.MethodGet,
			status:  http.StatusOK,
			wantRef: "#/components/schemas/Report",
		},
		{
			name:    "admin generate turn",
			path:    "/api/v1/admin/turn",
			method:  http.MethodPut,
			status:  http.StatusOK,
			wantRef: "#/components/schemas/StateResponse",
		},
		{
			name:    "put order",
			path:    "/api/v1/order",
			method:  http.MethodPut,
			status:  http.StatusAccepted,
			wantRef: "#/components/schemas/UserGamesOrder",
		},
		{
			name:    "get order",
			path:    "/api/v1/order",
			method:  http.MethodGet,
			status:  http.StatusOK,
			wantRef: "#/components/schemas/UserGamesOrder",
		},
		{
			name:    "healthz probe",
			path:    "/healthz",
			method:  http.MethodGet,
			status:  http.StatusOK,
			wantRef: "#/components/schemas/HealthzResponse",
		},
		{
			name:    "get battle",
			path:    "/api/v1/battle/{turn}/{uuid}",
			method:  http.MethodGet,
			status:  http.StatusOK,
			wantRef: "#/components/schemas/BattleReport",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			operation := getOpenAPIOperation(t, doc, tt.path, tt.method)
			assertSchemaRef(t, responseSchemaRef(t, operation, tt.status), tt.wantRef, tt.name+" response schema")
		})
	}
}

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

	doc := loadOpenAPISpec(t)

	tests := []struct {
		name   string
		path   string
		method string
		status int
	}{
		{
			name:   "command accepted",
			path:   "/api/v1/command",
			method: http.MethodPut,
			status: http.StatusAccepted,
		},
		{
			name:   "get order no content",
			path:   "/api/v1/order",
			method: http.MethodGet,
			status: http.StatusNoContent,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			operation := getOpenAPIOperation(t, doc, tt.path, tt.method)
			require.NotNil(t, operation.Responses, "operation must declare responses")
			response := operation.Responses.Status(tt.status)
			require.NotNil(t, response, "operation must declare %d response", tt.status)
			require.NotNil(t, response.Value, "%d response must have a value", tt.status)
			require.Empty(t, response.Value.Content, "%d response must carry no body", tt.status)
		})
	}
}

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

	doc := loadOpenAPISpec(t)
	schema := componentSchemaRef(t, doc, "UserGamesOrder")

	assertRequiredFields(t, schema, "game_id", "updatedAt", "cmd")

	gameIDSchema := schema.Value.Properties["game_id"]
	require.NotNil(t, gameIDSchema, "UserGamesOrder.game_id schema must exist")
	require.Equal(t, "uuid", gameIDSchema.Value.Format, "UserGamesOrder.game_id format must be uuid")

	updatedAtSchema := schema.Value.Properties["updatedAt"]
	require.NotNil(t, updatedAtSchema, "UserGamesOrder.updatedAt schema must exist")
	require.True(t, updatedAtSchema.Value.Type.Is("integer"), "UserGamesOrder.updatedAt must be integer")
	require.Equal(t, "int64", updatedAtSchema.Value.Format, "UserGamesOrder.updatedAt format must be int64")

	cmdSchema := schema.Value.Properties["cmd"]
	require.NotNil(t, cmdSchema, "UserGamesOrder.cmd schema must exist")
	require.True(t, cmdSchema.Value.Type.Is("array"), "UserGamesOrder.cmd must be array")
	require.NotNil(t, cmdSchema.Value.Items, "UserGamesOrder.cmd items must be defined")
	assertSchemaRef(t, cmdSchema.Value.Items, "#/components/schemas/Command", "UserGamesOrder.cmd items schema")
}

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

	doc := loadOpenAPISpec(t)
	operation := getOpenAPIOperation(t, doc, "/api/v1/order", http.MethodGet)

	require.Equal(t, "getOrder", operation.OperationID, "GET /api/v1/order operation id")

	paramRefs := make(map[string]bool)
	for _, p := range operation.Parameters {
		require.NotNil(t, p.Value, "parameter must have value")
		paramRefs[p.Ref] = true
	}
	require.True(t, paramRefs["#/components/parameters/PlayerParam"], "GET /api/v1/order must reference PlayerParam")
	require.True(t, paramRefs["#/components/parameters/TurnParam"], "GET /api/v1/order must reference TurnParam")
}

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

	doc := loadOpenAPISpec(t)
	operation := getOpenAPIOperation(t, doc, "/api/v1/admin/init", http.MethodPost)

	assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/InitRequest", "init request schema")

	schema := componentSchemaRef(t, doc, "InitRequest")
	assertRequiredFields(t, schema, "races")

	racesSchema := schema.Value.Properties["races"]
	require.NotNil(t, racesSchema, "InitRequest.races schema must exist")
	require.Equal(t, uint64(10), racesSchema.Value.MinItems, "InitRequest.races minItems must be 10")
}

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

	doc := loadOpenAPISpec(t)

	tests := []struct {
		path   string
		method string
		opID   string
	}{
		{"/api/v1/admin/init", http.MethodPost, "adminInitGame"},
		{"/api/v1/admin/status", http.MethodGet, "adminGetGameStatus"},
		{"/api/v1/admin/turn", http.MethodPut, "adminGenerateTurn"},
		{"/api/v1/admin/race/banish", http.MethodPost, "adminBanishRace"},
	}

	for _, tt := range tests {
		t.Run(tt.opID, func(t *testing.T) {
			t.Parallel()

			operation := getOpenAPIOperation(t, doc, tt.path, tt.method)
			require.Equal(t, tt.opID, operation.OperationID, "operation id for %s %s", tt.method, tt.path)
		})
	}
}

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

	doc := loadOpenAPISpec(t)
	operation := getOpenAPIOperation(t, doc, "/api/v1/admin/race/banish", http.MethodPost)

	assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/BanishRequest", "banish request schema")

	if operation.Responses == nil {
		require.FailNow(t, "banish operation is missing responses")
	}
	noContent := operation.Responses.Status(http.StatusNoContent)
	require.NotNil(t, noContent, "banish operation must declare 204 response")
	require.NotNil(t, noContent.Value, "banish 204 response must have a value")

	schema := componentSchemaRef(t, doc, "BanishRequest")
	assertRequiredFields(t, schema, "race_name")

	raceNameSchema := schema.Value.Properties["race_name"]
	require.NotNil(t, raceNameSchema, "BanishRequest.race_name schema must exist")
	require.Equal(t, uint64(1), raceNameSchema.Value.MinLength, "BanishRequest.race_name minLength must be 1")
}

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

	doc := loadOpenAPISpec(t)
	schema := componentSchemaRef(t, doc, "StateResponse")

	assertRequiredFields(t, schema, "id", "turn", "stage", "player", "finished")

	finishedSchema := schema.Value.Properties["finished"]
	require.NotNil(t, finishedSchema, "StateResponse.finished schema must exist")
	require.True(t, finishedSchema.Value.Type.Is("boolean"), "StateResponse.finished must be boolean")
}

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

	doc := loadOpenAPISpec(t)

	for _, path := range []string{"/api/v1/command", "/api/v1/order"} {
		t.Run(path, func(t *testing.T) {
			t.Parallel()

			operation := getOpenAPIOperation(t, doc, path, http.MethodPut)
			assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/CommandRequest", path+" command request schema")
		})
	}

	schema := componentSchemaRef(t, doc, "CommandRequest")
	assertRequiredFields(t, schema, "actor", "cmd")

	cmdSchema := schema.Value.Properties["cmd"]
	require.NotNil(t, cmdSchema, "CommandRequest.cmd schema must exist")
	require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1")
}

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

	doc := loadOpenAPISpec(t)
	operation := getOpenAPIOperation(t, doc, "/api/v1/battle/{turn}/{uuid}", http.MethodGet)

	require.Equal(t, "getBattle", operation.OperationID, "GET /api/v1/battle/{turn}/{uuid} operation id")

	paramRefs := make(map[string]bool)
	for _, p := range operation.Parameters {
		require.NotNil(t, p.Value, "parameter must have value")
		paramRefs[p.Ref] = true
	}
	require.True(t, paramRefs["#/components/parameters/BattleTurnParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleTurnParam")
	require.True(t, paramRefs["#/components/parameters/BattleIDParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleIDParam")

	require.NotNil(t, operation.Responses, "operation must declare responses")
	notFound := operation.Responses.Status(http.StatusNotFound)
	require.NotNil(t, notFound, "operation must declare 404 response")
	require.NotNil(t, notFound.Value, "404 response must have a value")
}

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

	doc := loadOpenAPISpec(t)

	reportSchema := componentSchemaRef(t, doc, "BattleReport")
	assertRequiredFields(t, reportSchema, "id", "planet", "planetName", "races", "ships", "protocol")

	groupSchema := componentSchemaRef(t, doc, "BattleReportGroup")
	assertRequiredFields(t, groupSchema, "race", "className", "tech", "num", "numLeft", "loadType", "loadQuantity", "inBattle")

	actionSchema := componentSchemaRef(t, doc, "BattleActionReport")
	assertRequiredFields(t, actionSchema, "a", "sa", "d", "sd", "x")

	protocolSchema := reportSchema.Value.Properties["protocol"]
	require.NotNil(t, protocolSchema, "BattleReport.protocol schema must exist")
	require.True(t, protocolSchema.Value.Type.Is("array"), "BattleReport.protocol must be array")
	require.NotNil(t, protocolSchema.Value.Items, "BattleReport.protocol items must be defined")
	assertSchemaRef(t, protocolSchema.Value.Items, "#/components/schemas/BattleActionReport", "BattleReport.protocol items schema")

	shipsSchema := reportSchema.Value.Properties["ships"]
	require.NotNil(t, shipsSchema, "BattleReport.ships schema must exist")
	require.True(t, shipsSchema.Value.Type.Is("object"), "BattleReport.ships must be object")
	require.NotNil(t, shipsSchema.Value.AdditionalProperties.Schema, "BattleReport.ships additionalProperties must be a schema")
	assertSchemaRef(t, shipsSchema.Value.AdditionalProperties.Schema, "#/components/schemas/BattleReportGroup", "BattleReport.ships additionalProperties schema")
}

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

	doc := loadOpenAPISpec(t)

	summary := componentSchemaRef(t, doc, "BattleSummary")
	assertRequiredFields(t, summary, "id", "planet", "shots")

	report := componentSchemaRef(t, doc, "Report")
	battle := report.Value.Properties["battle"]
	require.NotNil(t, battle, "Report.battle schema must exist")
	require.True(t, battle.Value.Type.Is("array"), "Report.battle must be array")
	require.NotNil(t, battle.Value.Items, "Report.battle items must be defined")
	assertSchemaRef(t, battle.Value.Items, "#/components/schemas/BattleSummary", "Report.battle items schema")
}

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

	doc := loadOpenAPISpec(t)
	schema := componentSchemaRef(t, doc, "HealthzResponse")

	assertRequiredFields(t, schema, "status")

	statusSchema := schema.Value.Properties["status"]
	require.NotNil(t, statusSchema, "HealthzResponse.status schema must exist")
	require.Equal(t, []any{"ok"}, statusSchema.Value.Enum, "HealthzResponse.status enum must be [\"ok\"]")
}

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

	doc := loadOpenAPISpec(t)
	schema := componentSchemaRef(t, doc, "CommandType")

	enumValues := make([]string, 0, len(schema.Value.Enum))
	for _, v := range schema.Value.Enum {
		s, ok := v.(string)
		require.True(t, ok, "CommandType enum entry must be a string")
		enumValues = append(enumValues, s)
	}

	require.ElementsMatch(t, []string{
		"raceQuit",
		"raceVote",
		"raceRelation",
		"shipClassCreate",
		"shipClassMerge",
		"shipClassRemove",
		"shipGroupBreak",
		"shipGroupLoad",
		"shipGroupUnload",
		"shipGroupSend",
		"shipGroupUpgrade",
		"shipGroupMerge",
		"shipGroupDismantle",
		"shipGroupTransfer",
		"shipGroupJoinFleet",
		"fleetMerge",
		"fleetSend",
		"scienceCreate",
		"scienceRemove",
		"planetRename",
		"planetProduce",
		"planetRouteSet",
		"planetRouteRemove",
	}, enumValues)
}

// helpers (modelled after user/openapi_contract_test.go)

func loadOpenAPISpec(t *testing.T) *openapi3.T {
	t.Helper()

	_, thisFile, _, ok := runtime.Caller(0)
	if !ok {
		require.FailNow(t, "runtime.Caller failed")
	}

	specPath := filepath.Join(filepath.Dir(thisFile), "openapi.yaml")
	loader := openapi3.NewLoader()
	doc, err := loader.LoadFromFile(specPath)
	if err != nil {
		require.Failf(t, "test failed", "load spec %s: %v", specPath, err)
	}
	if doc == nil {
		require.Failf(t, "test failed", "load spec %s: returned nil document", specPath)
	}
	if doc.Info == nil {
		require.Failf(t, "test failed", "load spec %s: missing info section", specPath)
	}
	if doc.Info.Version != "v1" {
		require.Failf(t, "test failed", "spec %s version = %q, want v1", specPath, doc.Info.Version)
	}
	if err := doc.Validate(context.Background()); err != nil {
		require.Failf(t, "test failed", "validate spec %s: %v", specPath, err)
	}

	return doc
}

func getOpenAPIOperation(t *testing.T, doc *openapi3.T, path string, method string) *openapi3.Operation {
	t.Helper()

	if doc.Paths == nil {
		require.Failf(t, "test failed", "spec is missing paths while looking up %s %s", method, path)
	}
	pathItem := doc.Paths.Value(path)
	if pathItem == nil {
		require.Failf(t, "test failed", "spec is missing path %s", path)
	}
	operation := pathItem.GetOperation(method)
	if operation == nil {
		require.Failf(t, "test failed", "spec is missing %s operation for path %s", method, path)
	}

	return operation
}

func requestSchemaRef(t *testing.T, operation *openapi3.Operation) *openapi3.SchemaRef {
	t.Helper()

	if operation.RequestBody == nil || operation.RequestBody.Value == nil {
		require.FailNow(t, "operation is missing request body")
	}
	mediaType := operation.RequestBody.Value.Content.Get("application/json")
	if mediaType == nil || mediaType.Schema == nil {
		require.FailNow(t, "operation is missing application/json request schema")
	}

	return mediaType.Schema
}

func responseSchemaRef(t *testing.T, operation *openapi3.Operation, status int) *openapi3.SchemaRef {
	t.Helper()

	if operation.Responses == nil {
		require.Failf(t, "test failed", "operation is missing responses for status %d", status)
	}
	response := operation.Responses.Status(status)
	if response == nil || response.Value == nil {
		require.Failf(t, "test failed", "operation is missing response for status %d", status)
	}
	mediaType := response.Value.Content.Get("application/json")
	if mediaType == nil || mediaType.Schema == nil {
		require.Failf(t, "test failed", "operation response %d is missing application/json schema", status)
	}

	return mediaType.Schema
}

func componentSchemaRef(t *testing.T, doc *openapi3.T, name string) *openapi3.SchemaRef {
	t.Helper()

	if doc.Components == nil {
		require.Failf(t, "test failed", "spec is missing components while looking up schema %s", name)
	}
	schema := doc.Components.Schemas[name]
	if schema == nil || schema.Value == nil {
		require.Failf(t, "test failed", "spec is missing schema %s", name)
	}

	return schema
}

func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want string, name string) {
	t.Helper()

	if schemaRef == nil {
		require.Failf(t, "test failed", "%s schema ref is nil", name)
	}
	if schemaRef.Ref != want {
		require.Failf(t, "test failed", "%s ref = %q, want %q", name, schemaRef.Ref, want)
	}
}

func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) {
	t.Helper()

	required := append([]string(nil), schemaRef.Value.Required...)
	slices.Sort(required)
	want := append([]string(nil), fields...)
	slices.Sort(want)
	if !slices.Equal(required, want) {
		require.Failf(t, "test failed", "schema required fields = %v, want %v", required, want)
	}
}
