package server

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"testing"

	"galaxy/backend/internal/server/middleware/basicauth"

	"github.com/getkin/kin-openapi/openapi3"
	"github.com/getkin/kin-openapi/openapi3filter"
	"github.com/getkin/kin-openapi/routers"
	"github.com/getkin/kin-openapi/routers/gorillamux"
)

// contractStubAdminPassword is the password the contract test injects
// into the stub Basic Auth verifier wired by NewRouter. The value
// never leaves the test binary; production wires the verifier from
// the Postgres-backed admin.Service.
const contractStubAdminPassword = "contract-test-secret"

// stubUserID is the deterministic UUID injected into `X-User-ID` for the
// authenticated user surface.
const stubUserID = "00000000-0000-0000-0000-0000000000a1"

// pathParamStubs lists the deterministic substitutions used to fill the path
// templates declared in `openapi.yaml`. Every parameter that appears in a path
// must have an entry here; the test fails loudly if a new parameter is added
// to the spec without updating this map.
var pathParamStubs = map[string]string{
	"game_id": "00000000-0000-0000-0000-000000000001",
	"application_id": "00000000-0000-0000-0000-000000000002",
	"invite_id": "00000000-0000-0000-0000-000000000003",
	"membership_id": "00000000-0000-0000-0000-000000000004",
	"notification_id": "00000000-0000-0000-0000-000000000005",
	"delivery_id": "00000000-0000-0000-0000-000000000006",
	"user_id": "00000000-0000-0000-0000-000000000007",
	"device_session_id": "00000000-0000-0000-0000-000000000008",
	"battle_id": "00000000-0000-0000-0000-000000000009",
	"message_id": "00000000-0000-0000-0000-00000000000a",
	"id": "1.2.3",
	"username": "alice",
	"turn": "42",
}

// queryParamStubs lists the deterministic substitutions used to fill
// query-string parameters declared in `openapi.yaml`. Every required
// query parameter must have an entry here; optional ones can stay
// blank (the contract test omits them when no stub is registered).
var queryParamStubs = map[string]string{
	"turn": "42",
}

// requestBodyStubs lists the JSON request bodies the contract test sends for
// each operationId. Operations missing from the map default to an empty
// object `{}`, which is a valid placeholder thanks to `additionalProperties:
// true` in the matching schemas. Operations that require specific fields
// listed in `openapi.yaml` get a hand-crafted body here.
var requestBodyStubs = map[string]map[string]any{
	"publicAuthSendEmailCode": {
		"email": "pilot@example.test",
	},
	"publicAuthConfirmEmailCode": {
		"challenge_id": "challenge-123",
		"code": "654321",
		"client_public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
		"time_zone": "Europe/Kaliningrad",
	},
	"adminAdminAccountsCreate": {
		"username": "carol",
		"password": "carol-secret",
	},
	"adminAdminAccountsResetPassword": {
		"password": "carol-new-secret",
	},
	"adminEngineVersionsCreate": {
		"version": "1.2.3",
		"image_ref": "registry.test/galaxy/engine:1.2.3",
	},
	"adminRuntimesPatch": {
		"target_version": "1.2.4",
	},
	"userLobbyRaceNamesRegister": {
		"name": "AndromedaConfederacy",
	},
	"adminUsersAddSanction": {
		"sanction_code": "permanent_block",
		"scope": "platform",
		"reason_code": "tos_violation",
		"actor": map[string]any{
			"type": "admin",
			"id": "operator",
		},
	},
	"adminUsersAddLimit": {
		"limit_code": "max_active_games",
		"value": 3,
		"reason_code": "manual_review",
		"actor": map[string]any{
			"type": "admin",
			"id": "operator",
		},
	},
	"adminUsersAddEntitlement": {
		"tier": "monthly",
		"source": "admin",
		"actor": map[string]any{
			"type": "admin",
			"id": "operator",
		},
	},
	"userLobbyGamesCreate": {
		"game_name": "Contract Test Game",
		"visibility": "private",
		"min_players": 2,
		"max_players": 8,
		"start_gap_hours": 24,
		"start_gap_players": 2,
		"enrollment_ends_at": "2099-01-02T03:04:05Z",
		"turn_schedule": "0 0 * * *",
		"target_engine_version": "1.0.0",
	},
	"adminGamesCreate": {
		"game_name": "Contract Test Public Game",
		"min_players": 4,
		"max_players": 12,
		"start_gap_hours": 12,
		"start_gap_players": 4,
		"enrollment_ends_at": "2099-01-02T03:04:05Z",
		"turn_schedule": "0 6 * * *",
		"target_engine_version": "1.0.0",
	},
	"userLobbyApplicationsSubmit": {
		"race_name": "ContractTestRace",
	},
	"userLobbyInvitesIssue": {
		"invited_user_id": pathParamStubs["user_id"],
		"race_name": "ContractTestRace",
	},
	"adminGamesBanMember": {
		"user_id": pathParamStubs["user_id"],
		"reason": "ToS violation",
	},
	"userMailSendPersonal": {
		"recipient_user_id": pathParamStubs["user_id"],
		"subject": "Contract test subject",
		"body": "Contract test body",
	},
	"userMailSendAdmin": {
		"target": "user",
		"recipient_user_id": pathParamStubs["user_id"],
		"subject": "Contract test admin subject",
		"body": "Contract test admin body",
	},
	"adminDiplomailSend": {
		"target": "user",
		"recipient_user_id": pathParamStubs["user_id"],
		"subject": "Contract test admin subject",
		"body": "Contract test admin body",
	},
	"userMailSendBroadcast": {
		"subject": "Contract test paid broadcast",
		"body": "Contract test paid broadcast body",
	},
	"adminDiplomailBroadcast": {
		"scope": "all_running",
		"subject": "Contract test multi-game broadcast",
		"body": "Contract test multi-game broadcast body",
	},
	"adminDiplomailCleanup": {
		"older_than_years": 1,
	},
}

// TestOpenAPIContract is the top-level OpenAPI contract test. It
// validates that:
//
//  1. backend/openapi.yaml is well-formed;
//  2. every documented operation maps to a registered gin handler;
//  3. each handler returns a representable HTTP response that satisfies
//     the declared response schema (any defensive `501 not_implemented`
//     placeholder also conforms).
func TestOpenAPIContract(t *testing.T) {
	t.Parallel()

	doc, specPath := loadOpenAPISpec(t)
	spec := mustValidateSpec(t, doc, specPath)

	router := mustNewGorillamuxRouter(t, doc)
	engine := mustBuildEngine(t)

	expectedOps := collectOperations(t, doc)
	if len(expectedOps) == 0 {
		t.Fatalf("openapi.yaml declares no operations")
	}

	_ = spec
	processed := 0
	for _, op := range expectedOps {
		t.Run(op.method+" "+op.path, func(t *testing.T) {
			t.Parallel()
			runContractCase(t, router, engine, op)
		})
		processed++
	}

	t.Logf("contract test exercised %d operations from %s", processed, specPath)
}

type contractOperation struct {
	method string
	path string
	operationID string
	op *openapi3.Operation
}

func collectOperations(t *testing.T, doc *openapi3.T) []contractOperation {
	t.Helper()

	var ops []contractOperation
	for path, item := range doc.Paths.Map() {
		if item == nil {
			continue
		}
		for method, op := range item.Operations() {
			if op == nil {
				continue
			}
			ops = append(ops, contractOperation{
				method: method,
				path: path,
				operationID: op.OperationID,
				op: op,
			})
		}
	}
	sort.Slice(ops, func(i, j int) bool {
		if ops[i].path == ops[j].path {
			return ops[i].method < ops[j].method
		}
		return ops[i].path < ops[j].path
	})
	return ops
}

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

	_, thisFile, _, ok := runtime.Caller(0)
	if !ok {
		t.Fatalf("runtime.Caller(0) failed")
	}
	specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "openapi.yaml")

	loader := openapi3.NewLoader()
	loader.IsExternalRefsAllowed = false
	doc, err := loader.LoadFromFile(specPath)
	if err != nil {
		t.Fatalf("load openapi spec %s: %v", specPath, err)
	}
	return doc, specPath
}

func mustValidateSpec(t *testing.T, doc *openapi3.T, specPath string) *openapi3.T {
	t.Helper()
	if err := doc.Validate(context.Background()); err != nil {
		t.Fatalf("openapi spec %s did not validate: %v", specPath, err)
	}
	if doc.Info == nil || doc.Info.Version != "v1" {
		t.Fatalf("openapi spec must declare info.version v1, got %+v", doc.Info)
	}
	return doc
}

func mustNewGorillamuxRouter(t *testing.T, doc *openapi3.T) routers.Router {
	t.Helper()
	router, err := gorillamux.NewRouter(doc)
	if err != nil {
		t.Fatalf("build gorillamux router: %v", err)
	}
	return router
}

func mustBuildEngine(t *testing.T) http.Handler {
	t.Helper()
	verifier := basicauth.NewStaticVerifier(contractStubAdminPassword)
	handler, err := NewRouter(RouterDependencies{
		AdminVerifier: verifier,
	})
	if err != nil {
		t.Fatalf("build router: %v", err)
	}
	return handler
}

func runContractCase(t *testing.T, router routers.Router, engine http.Handler, c contractOperation) {
	t.Helper()
	if c.operationID == "" {
		t.Fatalf("operation %s %s has no operationId", c.method, c.path)
	}

	req := buildRequest(t, c)

	route, pathParams, err := router.FindRoute(req)
	if err != nil {
		t.Fatalf("find route for %s %s: %v", c.method, req.URL.Path, err)
	}

	requestInput := &openapi3filter.RequestValidationInput{
		Request: req,
		PathParams: pathParams,
		Route: route,
		Options: &openapi3filter.Options{
			AuthenticationFunc: openapi3filter.NoopAuthenticationFunc,
		},
	}
	if err := openapi3filter.ValidateRequest(req.Context(), requestInput); err != nil {
		t.Fatalf("ValidateRequest %s %s (%s): %v", c.method, c.path, c.operationID, err)
	}

	recorder := httptest.NewRecorder()
	engine.ServeHTTP(recorder, req)

	expectPlaceholder := groupForPath(c.path) != "probe"
	if expectPlaceholder {
		if got, want := recorder.Code, http.StatusNotImplemented; got != want {
			t.Fatalf("operation %s %s (%s) returned status %d, want %d (body: %s)",
				c.method, c.path, c.operationID, got, want, recorder.Body.String())
		}
	} else if recorder.Code/100 != 2 {
		t.Fatalf("probe operation %s %s (%s) returned non-2xx status %d (body: %s)",
			c.method, c.path, c.operationID, recorder.Code, recorder.Body.String())
	}
	if ct := recorder.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		t.Fatalf("operation %s %s (%s) returned Content-Type %q, want application/json", c.method, c.path, c.operationID, ct)
	}

	responseInput := &openapi3filter.ResponseValidationInput{
		RequestValidationInput: requestInput,
		Status: recorder.Code,
		Header: recorder.Header(),
		Options: &openapi3filter.Options{
			IncludeResponseStatus: true,
		},
	}
	responseInput.SetBodyBytes(recorder.Body.Bytes())
	if err := openapi3filter.ValidateResponse(req.Context(), responseInput); err != nil {
		t.Fatalf("ValidateResponse %s %s (%s): %v", c.method, c.path, c.operationID, err)
	}
}

func buildRequest(t *testing.T, c contractOperation) *http.Request {
	t.Helper()

	target := substitutePathParams(t, c.path)
	if query := buildQuery(t, c); query != "" {
		target += "?" + query
	}
	url := "http://backend.internal" + target

	body := bodyFor(t, c)

	req, err := http.NewRequest(c.method, url, body.reader)
	if err != nil {
		t.Fatalf("construct request %s %s: %v", c.method, url, err)
	}
	if body.contentType != "" {
		req.Header.Set("Content-Type", body.contentType)
	}
	req.Header.Set("Accept", "application/json")

	switch groupForPath(c.path) {
	case "user":
		req.Header.Set("X-User-ID", stubUserID)
	case "admin":
		req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("operator:"+contractStubAdminPassword)))
	}
	req = req.WithContext(context.Background())
	return req
}

type requestBody struct {
	reader io.Reader
	contentType string
}

func bodyFor(t *testing.T, c contractOperation) requestBody {
	t.Helper()

	if c.op.RequestBody == nil || c.op.RequestBody.Value == nil {
		return requestBody{}
	}
	media := c.op.RequestBody.Value.Content.Get("application/json")
	if media == nil {
		return requestBody{}
	}

	stub, ok := requestBodyStubs[c.operationID]
	if !ok {
		stub = map[string]any{}
	}
	encoded, err := json.Marshal(stub)
	if err != nil {
		t.Fatalf("marshal request body for %s: %v", c.operationID, err)
	}
	return requestBody{
		reader: bytes.NewReader(encoded),
		contentType: "application/json",
	}
}

func buildQuery(t *testing.T, c contractOperation) string {
	t.Helper()
	if c.op == nil {
		return ""
	}
	values := make([]string, 0, len(c.op.Parameters))
	for _, p := range c.op.Parameters {
		if p == nil || p.Value == nil {
			continue
		}
		if p.Value.In != "query" {
			continue
		}
		stub, ok := queryParamStubs[p.Value.Name]
		if !ok {
			if p.Value.Required {
				t.Fatalf("operation %q requires query parameter %q with no stub registered", c.operationID, p.Value.Name)
			}
			continue
		}
		values = append(values, p.Value.Name+"="+stub)
	}
	return strings.Join(values, "&")
}

func substitutePathParams(t *testing.T, templated string) string {
	t.Helper()

	result := templated
	for {
		open := strings.Index(result, "{")
		if open < 0 {
			break
		}
		close := strings.Index(result[open:], "}")
		if close < 0 {
			t.Fatalf("malformed path template %q", templated)
		}
		name := result[open+1 : open+close]
		value, ok := pathParamStubs[name]
		if !ok {
			t.Fatalf("path template %q references unknown parameter %q", templated, name)
		}
		result = result[:open] + value + result[open+close+1:]
	}
	return result
}

// groupForPath returns the route family that contractOperation.path belongs
// to. The classification drives test-side header injection (X-User-ID,
// Authorization).
func groupForPath(path string) string {
	switch {
	case strings.HasPrefix(path, "/api/v1/public"):
		return "public"
	case strings.HasPrefix(path, "/api/v1/user"):
		return "user"
	case strings.HasPrefix(path, "/api/v1/admin"):
		return "admin"
	case strings.HasPrefix(path, "/api/v1/internal"):
		return "internal"
	default:
		return "probe"
	}
}
