package externaladmin

import (
	"bytes"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"

	"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 TestChangePasswordRejectsCurrentPasswordWithoutClearingGateOrRevokingSessions(t *testing.T) {
	service, mock, cleanup := newPasswordReuseFixture(t, "initial-password")
	defer cleanup()

	err := service.ChangePassword(t.Context(), SessionPrincipal{
		AccountID: 7, SessionID: 21, AppCode: "fami",
	}, ChangePasswordInput{CurrentPassword: "initial-password", NewPassword: "initial-password"})
	if !errors.Is(err, ErrPasswordReused) {
		t.Fatalf("change password error = %v, want ErrPasswordReused", err)
	}
	// The mock intentionally has no UPDATE expectation. Any attempt to clear
	// password_change_required or revoke sessions makes the transaction/test fail.
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("password reuse performed a write: %v", err)
	}
}

func TestChangePasswordHandlerMapsPasswordReuseToExplicitBadRequest(t *testing.T) {
	service, mock, cleanup := newPasswordReuseFixture(t, "initial-password")
	defer cleanup()
	handler := &Handler{service: service}

	gin.SetMode(gin.TestMode)
	engine := gin.New()
	engine.POST("/change-password", func(c *gin.Context) {
		c.Set(contextPrincipal, SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"})
		c.Next()
	}, handler.ChangePassword)
	body := bytes.NewBufferString(`{"currentPassword":"initial-password","newPassword":"initial-password"}`)
	request := httptest.NewRequest(http.MethodPost, "/change-password", body)
	request.Header.Set("Content-Type", "application/json")
	responseRecorder := httptest.NewRecorder()
	engine.ServeHTTP(responseRecorder, request)

	if responseRecorder.Code != http.StatusBadRequest {
		t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
	}
	var responseBody struct {
		Message string `json:"message"`
	}
	if err := json.Unmarshal(responseRecorder.Body.Bytes(), &responseBody); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if responseBody.Message != "新密码不能与当前密码相同" {
		t.Fatalf("message = %q", responseBody.Message)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("password reuse handler performed a write: %v", err)
	}
}

func TestAllPasswordWritersRejectWhitespaceOnlyCredential(t *testing.T) {
	// A non-nil inert DB is enough because all three methods must reject the shared
	// validation error before any App/user lookup or database write can run.
	service := NewService(&gorm.DB{}, nil, Config{})
	tests := []struct {
		name string
		call func() error
	}{
		{
			name: "create",
			call: func() error {
				_, err := service.CreateAccount(t.Context(), CreateInput{
					AppCode: "fami", TargetUserID: "123456", Username: "operator", Password: "        ", CreatedByAdminID: 1,
				})
				return err
			},
		},
		{
			name: "reset",
			call: func() error {
				_, err := service.ResetPassword(t.Context(), "fami", 7, "        ")
				return err
			},
		},
		{
			name: "change",
			call: func() error {
				return service.ChangePassword(t.Context(), SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"}, ChangePasswordInput{
					CurrentPassword: "initial-password", NewPassword: "        ",
				})
			},
		},
	}
	for _, testCase := range tests {
		t.Run(testCase.name, func(t *testing.T) {
			if err := testCase.call(); !errors.Is(err, ErrPasswordBlank) {
				t.Fatalf("error = %v, want ErrPasswordBlank", err)
			}
		})
	}
}

func TestPasswordHandlersReturnExplicitWhitespaceValidationMessage(t *testing.T) {
	tests := []struct {
		name        string
		path        string
		body        string
		wantMessage string
		register    func(*gin.Engine, *Handler)
	}{
		{
			name: "create", path: "/create", wantMessage: "密码不能全部为空白字符",
			body: `{"targetUserId":"123456","username":"operator","password":"        "}`,
			register: func(engine *gin.Engine, handler *Handler) {
				engine.POST("/create", func(c *gin.Context) {
					c.Set(adminmiddleware.ContextUserID, uint(1))
					c.Set(adminmiddleware.ContextPermissions, []string{"external-admin-user:permissions"})
					c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
					c.Next()
				}, handler.CreateAccount)
			},
		},
		{
			name: "reset", path: "/reset/7", wantMessage: "密码不能全部为空白字符",
			body: `{"password":"        "}`,
			register: func(engine *gin.Engine, handler *Handler) {
				engine.POST("/reset/:id", func(c *gin.Context) {
					c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
					c.Next()
				}, handler.ResetPassword)
			},
		},
		{
			name: "change", path: "/change", wantMessage: "新密码不能全部为空白字符",
			body: `{"currentPassword":"initial-password","newPassword":"        "}`,
			register: func(engine *gin.Engine, handler *Handler) {
				engine.POST("/change", func(c *gin.Context) {
					c.Set(contextPrincipal, SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"})
					c.Next()
				}, handler.ChangePassword)
			},
		},
	}
	for _, testCase := range tests {
		t.Run(testCase.name, func(t *testing.T) {
			gin.SetMode(gin.TestMode)
			handler := &Handler{service: NewService(&gorm.DB{}, nil, Config{})}
			engine := gin.New()
			testCase.register(engine, handler)
			request := httptest.NewRequest(http.MethodPost, testCase.path, bytes.NewBufferString(testCase.body))
			request.Header.Set("Content-Type", "application/json")
			responseRecorder := httptest.NewRecorder()
			engine.ServeHTTP(responseRecorder, request)
			if responseRecorder.Code != http.StatusBadRequest {
				t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
			}
			var responseBody struct {
				Message string `json:"message"`
			}
			if err := json.Unmarshal(responseRecorder.Body.Bytes(), &responseBody); err != nil {
				t.Fatalf("decode response: %v", err)
			}
			if responseBody.Message != testCase.wantMessage {
				t.Fatalf("message = %q, want %q", responseBody.Message, testCase.wantMessage)
			}
		})
	}
}

func newPasswordReuseFixture(t *testing.T, currentPassword string) (*Service, sqlmock.Sqlmock, func()) {
	t.Helper()
	sqlDB, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create sql mock: %v", err)
	}
	adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
	if err != nil {
		sqlDB.Close()
		t.Fatalf("create gorm db: %v", err)
	}
	passwordHash, err := security.HashPassword(currentPassword)
	if err != nil {
		sqlDB.Close()
		t.Fatalf("hash fixture password: %v", err)
	}
	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
		WithArgs(uint64(7), "fami", 1).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "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", passwordHash, `[]`, "active", true, 0, 0, 1, 1, 1))
	mock.ExpectCommit()
	return NewService(adminDB, nil, Config{}), mock, func() { _ = sqlDB.Close() }
}
