package externaladmin

import (
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"hyapp-admin-server/internal/appctx"
	adminmiddleware "hyapp-admin-server/internal/middleware"
	"hyapp-admin-server/internal/security"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/gin-gonic/gin"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func TestRequireCSRFAcceptsOnlyMatchingCookieHeaderAndSessionHash(t *testing.T) {
	gin.SetMode(gin.TestMode)
	csrfToken := "csrf-secret"
	handler := &Handler{}
	engine := gin.New()
	engine.POST("/write", func(c *gin.Context) {
		c.Set(contextPrincipal, SessionPrincipal{CSRFTokenHash: security.HashToken(csrfToken)})
	}, handler.RequireCSRF(), func(c *gin.Context) { c.Status(http.StatusNoContent) })

	validRequest := httptest.NewRequest(http.MethodPost, "/write", nil)
	validRequest.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: csrfToken})
	validRequest.Header.Set(CSRFHeaderName, csrfToken)
	validResponse := httptest.NewRecorder()
	engine.ServeHTTP(validResponse, validRequest)
	if validResponse.Code != http.StatusNoContent {
		t.Fatalf("valid csrf status = %d body=%s", validResponse.Code, validResponse.Body.String())
	}

	invalidRequest := httptest.NewRequest(http.MethodPost, "/write", nil)
	invalidRequest.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: csrfToken})
	invalidRequest.Header.Set(CSRFHeaderName, "different")
	invalidResponse := httptest.NewRecorder()
	engine.ServeHTTP(invalidResponse, invalidRequest)
	if invalidResponse.Code != http.StatusForbidden {
		t.Fatalf("invalid csrf status = %d body=%s", invalidResponse.Code, invalidResponse.Body.String())
	}
}

func TestExternalSessionCookiesAreHostOnlyAndPathScoped(t *testing.T) {
	gin.SetMode(gin.TestMode)
	handler := &Handler{cfg: Config{SessionTTL: time.Hour, CookieSecure: true, CookieSameSite: "none"}}
	engine := gin.New()
	engine.POST("/login", func(c *gin.Context) {
		handler.setSessionCookies(c, "session-token", "csrf-token")
		c.Status(http.StatusNoContent)
	})
	response := httptest.NewRecorder()
	engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/login", nil))

	cookies := response.Result().Cookies()
	if len(cookies) != 2 {
		t.Fatalf("cookie count = %d, want 2", len(cookies))
	}
	for _, cookie := range cookies {
		if cookie.Path != CookiePath || cookie.Domain != "" || !cookie.Secure || cookie.SameSite != http.SameSiteNoneMode {
			t.Fatalf("unsafe cookie attributes: %+v", cookie)
		}
	}
	if !cookieByName(cookies, SessionCookieName).HttpOnly {
		t.Fatal("session cookie must be HttpOnly")
	}
	if cookieByName(cookies, CSRFCookieName).HttpOnly {
		t.Fatal("double-submit CSRF cookie must be readable")
	}
}

func TestExternalSessionCookiesAllowLocalHTTPWithLaxSameSite(t *testing.T) {
	gin.SetMode(gin.TestMode)
	handler := &Handler{cfg: Config{SessionTTL: time.Hour, CookieSecure: false, CookieSameSite: "lax"}}
	engine := gin.New()
	engine.POST("/login", func(c *gin.Context) {
		handler.setSessionCookies(c, "session-token", "csrf-token")
		c.Status(http.StatusNoContent)
	})
	response := httptest.NewRecorder()
	engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/login", nil))
	for _, cookie := range response.Result().Cookies() {
		if cookie.Secure || cookie.SameSite != http.SameSiteLaxMode {
			t.Fatalf("local cookie must work over HTTP with Lax SameSite: %+v", cookie)
		}
	}
}

func TestAuthRequiredRejectsMismatchedAppHeaderAndBindsOmittedHeaderToSessionApp(t *testing.T) {
	for _, testCase := range []struct {
		name       string
		headerApp  string
		wantStatus int
		wantBound  bool
	}{
		{name: "mismatch rejected", headerApp: "lalu", wantStatus: http.StatusForbidden},
		{name: "omitted header bound", headerApp: "", wantStatus: http.StatusNoContent, wantBound: true},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "active")
			defer cleanup()
			gin.SetMode(gin.TestMode)
			engine := gin.New()
			engine.GET("/protected", handler.AuthRequired(), func(c *gin.Context) {
				if testCase.wantBound {
					principal, ok := CurrentPrincipal(c)
					if !ok || principal.SessionID != 11 {
						t.Fatalf("session principal = %+v ok=%t", principal, ok)
					}
					if got := appctx.FromContext(c.Request.Context()); got != "fami" {
						t.Fatalf("request app = %q, want session app fami", got)
					}
					if got := c.Writer.Header().Get(appctx.HeaderAppCode); got != "fami" {
						t.Fatalf("response app header = %q", got)
					}
					if got := adminmiddleware.CurrentUserID(c); got != 9001 {
						t.Fatalf("bound App operator = %d", got)
					}
				}
				c.Status(http.StatusNoContent)
			})

			request := httptest.NewRequest(http.MethodGet, "/protected", nil)
			request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"})
			if testCase.headerApp != "" {
				request.Header.Set(appctx.HeaderAppCode, testCase.headerApp)
			}
			responseRecorder := httptest.NewRecorder()
			engine.ServeHTTP(responseRecorder, request)
			if responseRecorder.Code != testCase.wantStatus {
				t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
			}
			assertAuthFixtureExpectations(t, adminMock, userMock)
		})
	}
}

func TestAuthRequiredBlocksBusinessDirectlyWhenSessionAppWasDisabled(t *testing.T) {
	handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "disabled")
	defer cleanup()
	gin.SetMode(gin.TestMode)
	engine := gin.New()
	reachedBusiness := false
	// Intentionally omit /auth/me: this proves a stale browser cannot bypass the
	// App-state check by navigating straight to a business API.
	engine.GET("/business", handler.AuthRequired(), func(c *gin.Context) {
		reachedBusiness = true
		c.Status(http.StatusNoContent)
	})
	request := httptest.NewRequest(http.MethodGet, "/business", nil)
	request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"})
	responseRecorder := httptest.NewRecorder()
	engine.ServeHTTP(responseRecorder, request)
	if responseRecorder.Code != http.StatusUnauthorized || reachedBusiness {
		t.Fatalf("status=%d reached_business=%t body=%s", responseRecorder.Code, reachedBusiness, responseRecorder.Body.String())
	}
	assertAuthFixtureExpectations(t, adminMock, userMock)
}

func TestAuthRequiredFailsClosedWithoutRevokingOnAppDatabaseError(t *testing.T) {
	handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "error")
	defer cleanup()
	gin.SetMode(gin.TestMode)
	engine := gin.New()
	reachedBusiness := false
	engine.GET("/business", handler.AuthRequired(), func(c *gin.Context) {
		reachedBusiness = true
		c.Status(http.StatusNoContent)
	})
	request := httptest.NewRequest(http.MethodGet, "/business", nil)
	request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"})
	responseRecorder := httptest.NewRecorder()
	engine.ServeHTTP(responseRecorder, request)
	if responseRecorder.Code != http.StatusInternalServerError || reachedBusiness {
		t.Fatalf("status=%d reached_business=%t body=%s", responseRecorder.Code, reachedBusiness, responseRecorder.Body.String())
	}
	// The fixture has no revocation UPDATE expectation. A transient owner-DB error
	// must fail this request closed without converting a recoverable session to 401.
	assertAuthFixtureExpectations(t, adminMock, userMock)
}

func TestRequirePasswordChangedBlocksBusinessRoutes(t *testing.T) {
	gin.SetMode(gin.TestMode)
	handler := &Handler{}
	engine := gin.New()
	engine.GET("/business", func(c *gin.Context) {
		c.Set(contextPrincipal, SessionPrincipal{PasswordChangeRequired: true})
		c.Next()
	}, handler.RequirePasswordChanged(), func(c *gin.Context) {
		c.Status(http.StatusNoContent)
	})

	responseRecorder := httptest.NewRecorder()
	engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/business", nil))
	if responseRecorder.Code != http.StatusForbidden {
		t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
	}
}

func newAuthRequiredFixture(t *testing.T, rawToken string, appState string) (*Handler, sqlmock.Sqlmock, sqlmock.Sqlmock, func()) {
	t.Helper()
	adminSQL, adminMock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create admin sql mock: %v", err)
	}
	adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
	if err != nil {
		adminSQL.Close()
		t.Fatalf("create gorm db: %v", err)
	}
	userDB, userMock, err := sqlmock.New()
	if err != nil {
		adminSQL.Close()
		t.Fatalf("create user sql mock: %v", err)
	}
	nowMS := time.Now().UTC().UnixMilli()
	adminMock.ExpectQuery("SELECT \\* FROM `external_admin_sessions` WHERE token_hash = \\? AND revoked_at_ms = 0 AND expires_at_ms > \\?").
		WithArgs(security.HashToken(rawToken), sqlmock.AnyArg(), 1).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "account_id", "app_code", "token_hash", "csrf_token_hash", "ip", "user_agent", "expires_at_ms", "last_seen_at_ms", "revoked_at_ms", "revoke_reason", "created_at_ms",
		}).AddRow(11, 7, "fami", security.HashToken(rawToken), security.HashToken("csrf"), "127.0.0.1", "browser", nowMS+3600000, nowMS, 0, "", nowMS))
	adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE `external_admin_accounts`.`id` = \\?").
		WithArgs(uint64(7)).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "identity_type", "parent_account_id", "status",
			"password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
		}).AddRow(7, "fami", 9001, "operator", "hash", `["bd:view"]`, "country_manager", nil, "active", false, 0, 0, 1, nowMS, nowMS))
	appQuery := userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps WHERE app_code = \\? AND status = 'active' LIMIT 1").WithArgs("fami")
	switch appState {
	case "active":
		appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo"))
		userMock.ExpectQuery("SELECT u.region_id").
			WithArgs("fami", int64(9001)).
			WillReturnRows(sqlmock.NewRows([]string{"region_id"}).AddRow(12))
		adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE app_code = \\? AND parent_account_id IN \\(\\?\\) AND identity_type = \\? AND status = \\?").
			WithArgs("fami", uint64(7), "external_super_admin", "active", maxHierarchyAccounts+1).
			WillReturnRows(sqlmock.NewRows([]string{"id"}))
	case "disabled":
		appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}))
		adminMock.ExpectBegin()
		adminMock.ExpectExec("UPDATE `external_admin_sessions`").
			WithArgs("app_disabled", sqlmock.AnyArg(), uint64(11)).
			WillReturnResult(sqlmock.NewResult(0, 1))
		adminMock.ExpectCommit()
	case "error":
		appQuery.WillReturnError(errors.New("user database temporarily unavailable"))
	default:
		t.Fatalf("unknown app state %q", appState)
	}
	return &Handler{service: NewService(adminDB, userDB, Config{})}, adminMock, userMock, func() {
		_ = userDB.Close()
		_ = adminSQL.Close()
	}
}

func assertAuthFixtureExpectations(t *testing.T, adminMock sqlmock.Sqlmock, userMock sqlmock.Sqlmock) {
	t.Helper()
	if err := adminMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("admin auth sql expectations: %v", err)
	}
	if err := userMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("user auth sql expectations: %v", err)
	}
}

func cookieByName(cookies []*http.Cookie, name string) *http.Cookie {
	for _, cookie := range cookies {
		if cookie.Name == name {
			return cookie
		}
	}
	return &http.Cookie{}
}
