package middleware

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"reflect"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"

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

type recordingBrowserAuthenticator struct {
	claims     *hostruntime.SessionClaims
	expiresAt  time.Time
	err        error
	proofSeen  string
	calls      int
	touchedIDs []string
	bearerSeen bool
}

func (a *recordingBrowserAuthenticator) ValidateSession(context.Context, string) (*hostruntime.SessionClaims, error) {
	// Reaching the bearer path at all would mean the browser lane fell back to a
	// credential the page can read.
	a.bearerSeen = true
	return nil, errors.New("bearer validation must not be reached from the browser lane")
}

func (a *recordingBrowserAuthenticator) TouchSession(_ context.Context, sessionID string) {
	a.touchedIDs = append(a.touchedIDs, sessionID)
}

func (a *recordingBrowserAuthenticator) ValidateBrowserSession(_ context.Context, proof string) (hostruntime.BrowserSessionValidation, error) {
	a.calls++
	a.proofSeen = proof
	if a.err != nil {
		return hostruntime.BrowserSessionValidation{}, a.err
	}
	return hostruntime.BrowserSessionValidation{Claims: a.claims, ProofCookieExpiresAt: a.expiresAt}, nil
}

type capturingSessionWriter struct {
	sessionID string
	written   bool
}

func (w *capturingSessionWriter) WriteSession(_ *gin.Context, _ hostruntime.UserSession, sessionID string) {
	w.written = true
	w.sessionID = sessionID
}

func browserAuthClaims() *hostruntime.SessionClaims {
	return &hostruntime.SessionClaims{SessionID: "SESSION_A"}
}

// runBrowserSessionRequest drives the handler through the real session-control
// admission, so "admitted first-party request" is exercised rather than faked.
func runBrowserSessionRequest(t *testing.T, auth *recordingBrowserAuthenticator, header http.Header, admit bool) (*httptest.ResponseRecorder, *capturingSessionWriter, bool) {
	return runBrowserSessionRequestProtected(t, auth, header, admit, false)
}

func runBrowserSessionRequestProtected(t *testing.T, auth *recordingBrowserAuthenticator, header http.Header, admit, protect bool) (*httptest.ResponseRecorder, *capturingSessionWriter, bool) {
	t.Helper()
	gin.SetMode(gin.TestMode)

	writer := &capturingSessionWriter{}
	var failed bool
	responder := AuthFailureResponderFunc(func(c *gin.Context, _ AuthFailure) {
		failed = true
		c.Status(http.StatusUnauthorized)
	})

	router := gin.New()
	if admit {
		admission, err := NewSessionControlAdmission("https://app.example.com")
		if err != nil {
			t.Fatalf("NewSessionControlAdmission: %v", err)
		}
		router.Use(admission)
	}
	router.Use(RequireBrowserSession(auth, writer, responder))
	reached := false
	router.GET("/protected", func(c *gin.Context) {
		reached = true
		c.Status(http.StatusOK)
	})

	var handler http.Handler = router
	if protect {
		handler = ProtectSessionCredentialResponses(router)
	}

	req := httptest.NewRequest(http.MethodGet, "/protected", nil)
	req.Header = header.Clone()
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if failed && reached {
		t.Fatal("handler ran after an auth failure")
	}
	return rec, writer, reached
}

// admittedBrowserHeader carries everything session-control admission requires of a
// first-party request, plus the proof cookie.
func admittedBrowserHeader(cookieValue string) http.Header {
	header := http.Header{}
	header.Set(HeaderSessionControlIntent, SessionControlIntentValue)
	header.Set("Sec-Fetch-Site", "same-origin")
	header.Set("Origin", "https://app.example.com")
	if cookieValue != "" {
		header.Add("Cookie", SessionControlCookieName+"="+cookieValue)
	}
	return header
}

func TestRequireBrowserSessionAuthenticatesFromTheProofAlone(t *testing.T) {
	auth := &recordingBrowserAuthenticator{claims: browserAuthClaims()}

	rec, writer, reached := runBrowserSessionRequest(t, auth, admittedBrowserHeader(testProof), true)
	if rec.Code != http.StatusOK || !reached {
		t.Fatalf("status = %d reached = %v, want the request served", rec.Code, reached)
	}
	if auth.calls != 1 || auth.proofSeen != testProof {
		t.Fatalf("validator saw calls=%d proof=%q, want one call with the cookie value", auth.calls, auth.proofSeen)
	}
	if auth.bearerSeen {
		t.Fatal("the browser lane must never reach bearer validation")
	}
	if !writer.written || writer.sessionID != "SESSION_A" {
		t.Fatalf("session writer got %+v, want the resolved session installed", writer)
	}
	if len(auth.touchedIDs) != 1 || auth.touchedIDs[0] != "SESSION_A" {
		t.Fatalf("touched = %v, want the resolved session touched once", auth.touchedIDs)
	}
}

// TestRequireBrowserSessionRejectsAnAuthorizationHeader is the lane rule: the
// header is refused before any validation, so presenting a bearer cannot reach a
// credential-validating path on a browser-only route.
func TestRequireBrowserSessionRejectsAnAuthorizationHeader(t *testing.T) {
	auth := &recordingBrowserAuthenticator{claims: browserAuthClaims()}
	header := admittedBrowserHeader(testProof)
	header.Set("Authorization", "Bearer something")

	rec, writer, reached := runBrowserSessionRequest(t, auth, header, true)
	if rec.Code != http.StatusUnauthorized || reached {
		t.Fatalf("status = %d reached = %v, want rejection", rec.Code, reached)
	}
	if auth.calls != 0 {
		t.Fatal("a presented Authorization header must be refused before validation")
	}
	if auth.bearerSeen {
		t.Fatal("the browser lane must never reach bearer validation")
	}
	if writer.written {
		t.Fatal("no session may be installed for a rejected request")
	}
}

// TestRequireBrowserSessionRequiresFirstPartyAdmission separates authentication
// from admission. Without the intent header and origin evidence, an ambient cookie
// would be usable from contexts the browser attaches it to automatically.
func TestRequireBrowserSessionRequiresFirstPartyAdmission(t *testing.T) {
	auth := &recordingBrowserAuthenticator{claims: browserAuthClaims()}

	// A valid proof, but the admission middleware is not mounted, so the request
	// was never admitted as first-party.
	rec, _, reached := runBrowserSessionRequest(t, auth, admittedBrowserHeader(testProof), false)
	if rec.Code != http.StatusUnauthorized || reached {
		t.Fatalf("status = %d reached = %v, want rejection without admission", rec.Code, reached)
	}
	if auth.calls != 0 {
		t.Fatal("an unadmitted request must not reach session validation")
	}
}

func TestRequireBrowserSessionRejectsUnusableProofs(t *testing.T) {
	cases := map[string]string{
		"absent":    "",
		"malformed": "short",
		"quoted":    `"` + testProof + `"`,
	}
	for name, value := range cases {
		t.Run(name, func(t *testing.T) {
			auth := &recordingBrowserAuthenticator{claims: browserAuthClaims()}
			rec, _, reached := runBrowserSessionRequest(t, auth, admittedBrowserHeader(value), true)
			if rec.Code != http.StatusUnauthorized || reached {
				t.Fatalf("status = %d reached = %v, want rejection", rec.Code, reached)
			}
			if auth.calls != 0 {
				t.Fatalf("an unusable proof must not reach session validation (proof=%q)", auth.proofSeen)
			}
		})
	}
}

// TestRequireBrowserSessionRejectsDuplicateProofCookies keeps "exactly one
// structurally valid proof cookie" enforced at the lane boundary.
func TestRequireBrowserSessionRejectsDuplicateProofCookies(t *testing.T) {
	auth := &recordingBrowserAuthenticator{claims: browserAuthClaims()}
	header := admittedBrowserHeader(testProof)
	header.Add("Cookie", SessionControlCookieName+"="+testProof)

	rec, _, reached := runBrowserSessionRequest(t, auth, header, true)
	if rec.Code != http.StatusUnauthorized || reached {
		t.Fatalf("status = %d reached = %v, want rejection", rec.Code, reached)
	}
	if auth.calls != 0 {
		t.Fatal("duplicate cookies must not reach session validation")
	}
}

// TestRequireBrowserSessionRejectsAnUnresolvedSession covers every reason the
// authenticator can decline — expired, revoked, ambiguous — in one place: no claims
// means no principal.
func TestRequireBrowserSessionRejectsAnUnresolvedSession(t *testing.T) {
	for name, auth := range map[string]*recordingBrowserAuthenticator{
		"validator error":    {err: errors.New("revoked")},
		"no claims returned": {},
	} {
		t.Run(name, func(t *testing.T) {
			rec, writer, reached := runBrowserSessionRequest(t, auth, admittedBrowserHeader(testProof), true)
			if rec.Code != http.StatusUnauthorized || reached {
				t.Fatalf("status = %d reached = %v, want rejection", rec.Code, reached)
			}
			if writer.written {
				t.Fatal("no session may be installed when none was resolved")
			}
		})
	}
}

// TestBrowserSessionValidationCannotCarryABearer is a structural assertion rather
// than a behavioral one: the result type has no bearer field, so this lane cannot
// regress into handing the page a JS-readable credential. If someone adds one, this
// stops compiling and the reason is right here.
func TestBrowserSessionValidationCannotCarryABearer(t *testing.T) {
	validation := hostruntime.BrowserSessionValidation{}
	fields := []string{"Claims", "ProofCookieExpiresAt"}

	typ := reflect.TypeOf(validation)
	if typ.NumField() != len(fields) {
		t.Fatalf("BrowserSessionValidation has %d fields, want exactly %v — a bearer field would defeat the browser lane",
			typ.NumField(), fields)
	}
	for i, want := range fields {
		if got := typ.Field(i).Name; got != want {
			t.Fatalf("field %d = %q, want %q", i, got, want)
		}
	}
	for i := 0; i < typ.NumField(); i++ {
		if strings.Contains(strings.ToLower(typ.Field(i).Name), "bearer") ||
			strings.Contains(strings.ToLower(typ.Field(i).Name), "token") {
			t.Fatalf("field %q looks like a bearer credential", typ.Field(i).Name)
		}
	}
}

// TestRequireBrowserSessionReissuesTheSameProofOnRenewal covers the renewal path.
// The proof value is unchanged: a renewed session extends the cookie's lifetime,
// and rotating the proof belongs to the session owner on login and privilege
// change, not to an ordinary authenticated request.
func TestRequireBrowserSessionReissuesTheSameProofOnRenewal(t *testing.T) {
	auth := &recordingBrowserAuthenticator{
		claims:    browserAuthClaims(),
		expiresAt: time.Now().Add(2 * time.Hour),
	}

	rec, _, reached := runBrowserSessionRequestProtected(t, auth, admittedBrowserHeader(testProof), true, true)
	if rec.Code != http.StatusOK || !reached {
		t.Fatalf("status = %d reached = %v, want the request served", rec.Code, reached)
	}

	setCookie := rec.Header().Get("Set-Cookie")
	if setCookie == "" {
		t.Fatal("a renewed session must reissue the proof cookie")
	}
	if !strings.Contains(setCookie, SessionControlCookieName+"="+testProof) {
		t.Fatalf("Set-Cookie = %q, want the same proof value reissued", setCookie)
	}
	for _, attribute := range []string{"HttpOnly", "Secure", "SameSite=Strict"} {
		if !strings.Contains(setCookie, attribute) {
			t.Fatalf("Set-Cookie = %q, missing %s", setCookie, attribute)
		}
	}
	// The browser must never receive a bearer from this lane.
	if rec.Header().Get(HeaderRefreshedToken) != "" {
		t.Fatalf("browser lane leaked a bearer in %s", HeaderRefreshedToken)
	}
}

// TestRequireBrowserSessionFailsClosedWhenTheResponseIsUnprotected pins what happens
// to a renewal when the consumer forgot ProtectSessionCredentialResponses. The
// request fails rather than silently serving without reissuing, so a session whose
// cookie should have been extended cannot expire unnoticed.
func TestRequireBrowserSessionFailsClosedWhenTheResponseIsUnprotected(t *testing.T) {
	auth := &recordingBrowserAuthenticator{
		claims:    browserAuthClaims(),
		expiresAt: time.Now().Add(2 * time.Hour),
	}

	rec, _, reached := runBrowserSessionRequestProtected(t, auth, admittedBrowserHeader(testProof), true, false)
	if rec.Code != http.StatusUnauthorized || reached {
		t.Fatalf("status = %d reached = %v, want a fail-closed rejection", rec.Code, reached)
	}
	if rec.Header().Get("Set-Cookie") != "" {
		t.Fatal("no cookie may be written outside the protected response boundary")
	}
}
