package dialecttransport_test

import (
	"context"
	"net/http"
	"net/http/httptest"
	"reflect"
	"testing"

	"github.com/gin-gonic/gin"

	"github.com/pingcap-inc/nexuscrm-api-host/dialect"
	"github.com/pingcap-inc/nexuscrm-api-host/dialecttransport"
)

// policyDialect declares one route per supplied policy and returns a resolver
// that reports each route's policy, mirroring how a declaring owner supplies them
// without api-host's released Route struct having to carry the field.
func policyDialect(policies ...dialect.CredentialPolicy) (testDialect, dialecttransport.RouteCredentialPolicyResolver) {
	d := testDialect{
		name: "policy-dialect", pack: "", base: "/svc", vers: []string{"v1"},
		proj: notFoundProjector,
	}
	declared := map[string]dialect.CredentialPolicy{}
	for i, policy := range policies {
		routePath := "/r" + string(rune('0'+i))
		declared[routePath] = policy
		d.manifest = append(d.manifest, dialect.Route{
			Method: http.MethodGet,
			Path:   routePath,
			Gate:   dialect.Gate{Mode: dialect.GateNone},
			Handler: func(_ context.Context, _ *dialect.Request, _ dialect.Deps) (*dialect.Response, error) {
				return &dialect.Response{Status: http.StatusOK, Body: gin.H{"ok": true}}, nil
			},
		})
	}
	resolver := func(_ string, route dialect.Route) dialect.CredentialPolicy {
		return declared[route.Path]
	}
	return d, resolver
}

// markerStack returns a stack that records the policy it was selected for, so the
// test asserts the route actually ran the stack chosen for its declaration.
func markerStack(seen *[]string, label string) []gin.HandlerFunc {
	return []gin.HandlerFunc{func(c *gin.Context) {
		*seen = append(*seen, label)
		c.Next()
	}}
}

func TestCredentialPolicyZeroValueIsProgrammaticOnly(t *testing.T) {
	var undeclared dialect.CredentialPolicy
	if undeclared != dialect.CredentialProgrammaticOnly {
		t.Fatalf("zero value = %s, want programmatic_only", undeclared)
	}
	if undeclared.AdmitsBrowserAdmission() {
		t.Fatal("an undeclared route must not admit the browser lane")
	}
	if !undeclared.AdmitsProgrammaticCredential() {
		t.Fatal("an undeclared route must keep programmatic admission")
	}
}

// The released Route struct must keep its exact shape: adding a field would break
// any consumer that builds one with an unkeyed composite literal, which is why the
// credential policy is carried by a resolver instead of a new field. This pins the
// field list so a future change has to confront that compatibility question here.
func TestRouteStructShapeIsUnchanged(t *testing.T) {
	routeType := reflect.TypeOf(dialect.Route{})
	var got []string
	for i := 0; i < routeType.NumField(); i++ {
		field := routeType.Field(i)
		got = append(got, field.Name+" "+field.Type.String())
	}
	want := []string{
		"Method string",
		"Path string",
		"Gate dialect.Gate",
		"BodyMode dialect.RequestBodyMode",
		"BodyLimitBytes int64",
		"Handler dialect.Handler",
	}
	if len(got) != len(want) {
		t.Fatalf("Route has %d fields %v, want %d %v", len(got), got, len(want), want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("Route field %d = %q, want %q (changing the released struct breaks unkeyed literals)", i, got[i], want[i])
		}
	}
}

func TestCredentialPolicyAdmissionMatchesDeclaredSemantics(t *testing.T) {
	cases := []struct {
		policy       dialect.CredentialPolicy
		programmatic bool
		browser      bool
		name         string
	}{
		{dialect.CredentialProgrammaticOnly, true, false, "programmatic_only"},
		{dialect.CredentialBrowserOrProgrammatic, true, true, "browser_or_programmatic"},
		{dialect.CredentialBrowserOnly, false, true, "browser_only"},
		{dialect.CredentialBrowserEstablishment, false, true, "browser_establishment"},
		{dialect.CredentialBrowserTermination, false, true, "browser_termination"},
	}
	for _, tc := range cases {
		if got := tc.policy.String(); got != tc.name {
			t.Fatalf("String() = %q, want %q", got, tc.name)
		}
		if !tc.policy.Valid() {
			t.Fatalf("%s must be a valid policy", tc.name)
		}
		if got := tc.policy.AdmitsProgrammaticCredential(); got != tc.programmatic {
			t.Fatalf("%s AdmitsProgrammaticCredential = %v, want %v", tc.name, got, tc.programmatic)
		}
		if got := tc.policy.AdmitsBrowserAdmission(); got != tc.browser {
			t.Fatalf("%s AdmitsBrowserAdmission = %v, want %v", tc.name, got, tc.browser)
		}
	}
	// The three admission dimensions must stay independent, since widening means
	// gaining any one of them.
	dimensions := map[dialect.CredentialPolicy][3]bool{
		dialect.CredentialProgrammaticOnly:      {true, false, false},
		dialect.CredentialBrowserOrProgrammatic: {true, true, false},
		dialect.CredentialBrowserOnly:           {false, true, false},
		dialect.CredentialBrowserEstablishment:  {false, false, true},
		dialect.CredentialBrowserTermination:    {false, true, true},
	}
	for policy, want := range dimensions {
		got := [3]bool{
			policy.AdmitsProgrammaticCredential(),
			policy.AdmitsEstablishedBrowserSession(),
			policy.AdmitsUnauthenticatedFirstParty(),
		}
		if got != want {
			t.Fatalf("%s dimensions = %v, want %v", policy, got, want)
		}
		if policy.AdmitsBrowserAdmission() != (want[1] || want[2]) {
			t.Fatalf("%s browser admission disagrees with its dimensions", policy)
		}
	}

	if dialect.CredentialPolicy(200).Valid() {
		t.Fatal("an undeclared numeric value must not be valid")
	}
}

func TestBinderWithoutCredentialProviderKeepsTheBaseStack(t *testing.T) {
	gin.SetMode(gin.TestMode)
	router := gin.New()
	var seen []string
	base := []gin.HandlerFunc{func(c *gin.Context) {
		seen = append(seen, "base")
		c.Next()
	}}

	d, _ := policyDialect(dialect.CredentialProgrammaticOnly, dialect.CredentialBrowserOrProgrammatic)
	dialecttransport.Mount(router, []dialect.Dialect{d}, deps(), base,
		testUserFromContext, permsKnown(), map[string]bool{})

	for _, routePath := range []string{"/svc/v1/r0", "/svc/v1/r1"} {
		seen = nil
		if w := serve(router, routePath); w.Code != http.StatusOK {
			t.Fatalf("%s = %d, want 200", routePath, w.Code)
		}
		if len(seen) != 1 || seen[0] != "base" {
			t.Fatalf("%s ran %v, want the unchanged base stack", routePath, seen)
		}
	}
}

func TestBinderSelectsPerRouteStackFromDeclaredPolicy(t *testing.T) {
	gin.SetMode(gin.TestMode)
	router := gin.New()
	var seen []string
	provider := func(policy dialect.CredentialPolicy) []gin.HandlerFunc {
		return markerStack(&seen, policy.String())
	}

	d, resolver := policyDialect(
		dialect.CredentialProgrammaticOnly,
		dialect.CredentialBrowserOrProgrammatic,
		dialect.CredentialBrowserOnly,
	)
	dialecttransport.Mount(router, []dialect.Dialect{d}, deps(),
		[]gin.HandlerFunc{func(c *gin.Context) {
			seen = append(seen, "base")
			c.Next()
		}},
		testUserFromContext, permsKnown(), map[string]bool{},
		dialecttransport.WithCredentialStackProvider(provider),
		dialecttransport.WithRouteCredentialPolicyResolver(resolver))

	want := map[string]string{
		"/svc/v1/r0": "programmatic_only",
		"/svc/v1/r1": "browser_or_programmatic",
		"/svc/v1/r2": "browser_only",
	}
	for routePath, label := range want {
		seen = nil
		if w := serve(router, routePath); w.Code != http.StatusOK {
			t.Fatalf("%s = %d, want 200", routePath, w.Code)
		}
		if len(seen) != 1 || seen[0] != label {
			// The base stack must not also run: with a provider configured the
			// declared policy is the only thing that selects admission.
			t.Fatalf("%s ran %v, want exactly [%s]", routePath, seen, label)
		}
	}
}

func TestBinderFailsClosedOnCredentialPolicyDeclarations(t *testing.T) {
	gin.SetMode(gin.TestMode)
	okStack := func(dialect.CredentialPolicy) []gin.HandlerFunc {
		return []gin.HandlerFunc{func(c *gin.Context) { c.Next() }}
	}
	cases := map[string]func(){
		"resolver reports an unknown policy": func() {
			d, _ := policyDialect(dialect.CredentialProgrammaticOnly)
			dialecttransport.Mount(gin.New(), []dialect.Dialect{d}, deps(), base(),
				testUserFromContext, permsKnown(), map[string]bool{},
				dialecttransport.WithCredentialStackProvider(okStack),
				dialecttransport.WithRouteCredentialPolicyResolver(
					func(string, dialect.Route) dialect.CredentialPolicy {
						return dialect.CredentialPolicy(200)
					}))
		},
		"resolver without a stack provider": func() {
			d, resolver := policyDialect(dialect.CredentialBrowserOrProgrammatic)
			dialecttransport.Mount(gin.New(), []dialect.Dialect{d}, deps(), base(),
				testUserFromContext, permsKnown(), map[string]bool{},
				dialecttransport.WithRouteCredentialPolicyResolver(resolver))
		},
		"provider returns an empty stack": func() {
			d, resolver := policyDialect(dialect.CredentialBrowserOrProgrammatic)
			dialecttransport.Mount(gin.New(), []dialect.Dialect{d}, deps(), base(),
				testUserFromContext, permsKnown(), map[string]bool{},
				dialecttransport.WithCredentialStackProvider(
					func(dialect.CredentialPolicy) []gin.HandlerFunc { return nil }),
				dialecttransport.WithRouteCredentialPolicyResolver(resolver))
		},
		"provider returns a nil middleware": func() {
			d, resolver := policyDialect(dialect.CredentialProgrammaticOnly)
			dialecttransport.Mount(gin.New(), []dialect.Dialect{d}, deps(), base(),
				testUserFromContext, permsKnown(), map[string]bool{},
				dialecttransport.WithCredentialStackProvider(
					func(dialect.CredentialPolicy) []gin.HandlerFunc {
						return []gin.HandlerFunc{nil}
					}),
				dialecttransport.WithRouteCredentialPolicyResolver(resolver))
		},
	}
	for name, mount := range cases {
		t.Run(name, func(t *testing.T) {
			defer func() {
				if recover() == nil {
					t.Fatal("want mount to fail closed")
				}
			}()
			mount()
		})
	}
}

func TestRegisterAuthenticatedRouteRequiresAnExplicitPolicy(t *testing.T) {
	gin.SetMode(gin.TestMode)
	router := gin.New()
	var seen []string
	provider := func(policy dialect.CredentialPolicy) []gin.HandlerFunc {
		return markerStack(&seen, policy.String())
	}

	dialecttransport.RegisterAuthenticatedRoute(router, provider,
		dialect.CredentialBrowserEstablishment, http.MethodPost, "/host/session",
		func(c *gin.Context) { c.Status(http.StatusNoContent) })

	request := httptest.NewRequest(http.MethodPost, "/host/session", nil)
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusNoContent {
		t.Fatalf("host route = %d, want 204", recorder.Code)
	}
	if len(seen) != 1 || seen[0] != "browser_establishment" {
		t.Fatalf("host route ran %v, want the declared establishment stack", seen)
	}
}

func TestRegisterAuthenticatedRouteFailsClosed(t *testing.T) {
	gin.SetMode(gin.TestMode)
	okProvider := func(dialect.CredentialPolicy) []gin.HandlerFunc {
		return []gin.HandlerFunc{func(c *gin.Context) { c.Next() }}
	}
	handler := func(c *gin.Context) { c.Status(http.StatusOK) }

	cases := map[string]func(){
		"nil router": func() {
			dialecttransport.RegisterAuthenticatedRoute(nil, okProvider,
				dialect.CredentialBrowserOnly, http.MethodGet, "/host/session", handler)
		},
		"nil provider": func() {
			dialecttransport.RegisterAuthenticatedRoute(gin.New(), nil,
				dialect.CredentialBrowserOnly, http.MethodGet, "/host/session", handler)
		},
		"unknown policy": func() {
			dialecttransport.RegisterAuthenticatedRoute(gin.New(), okProvider,
				dialect.CredentialPolicy(9), http.MethodGet, "/host/session", handler)
		},
		"invalid method": func() {
			dialecttransport.RegisterAuthenticatedRoute(gin.New(), okProvider,
				dialect.CredentialBrowserOnly, "FETCH", "/host/session", handler)
		},
		"no handler": func() {
			dialecttransport.RegisterAuthenticatedRoute(gin.New(), okProvider,
				dialect.CredentialBrowserOnly, http.MethodGet, "/host/session")
		},
		"nil handler": func() {
			dialecttransport.RegisterAuthenticatedRoute(gin.New(), okProvider,
				dialect.CredentialBrowserOnly, http.MethodGet, "/host/session", nil)
		},
		"provider returns an empty stack": func() {
			dialecttransport.RegisterAuthenticatedRoute(gin.New(),
				func(dialect.CredentialPolicy) []gin.HandlerFunc { return nil },
				dialect.CredentialBrowserOnly, http.MethodGet, "/host/session", handler)
		},
	}
	for name, register := range cases {
		t.Run(name, func(t *testing.T) {
			defer func() {
				if recover() == nil {
					t.Fatal("want registration to fail closed")
				}
			}()
			register()
		})
	}
}
