package api

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"os"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/grafana/grafana/pkg/api/routing"
	"github.com/grafana/grafana/pkg/services/accesscontrol"
	"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
	"github.com/grafana/grafana/pkg/services/auth/authtest"
	"github.com/grafana/grafana/pkg/services/authn/authntest"
	"github.com/grafana/grafana/pkg/services/featuremgmt"
	"github.com/grafana/grafana/pkg/services/ldap"
	"github.com/grafana/grafana/pkg/services/ldap/multildap"
	"github.com/grafana/grafana/pkg/services/ldap/service"
	"github.com/grafana/grafana/pkg/services/login"
	"github.com/grafana/grafana/pkg/services/login/authinfotest"
	"github.com/grafana/grafana/pkg/services/org"
	"github.com/grafana/grafana/pkg/services/org/orgtest"
	"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
	"github.com/grafana/grafana/pkg/services/user"
	"github.com/grafana/grafana/pkg/services/user/usertest"
	"github.com/grafana/grafana/pkg/setting"
	"github.com/grafana/grafana/pkg/web/webtest"
)

type LDAPMock struct {
	Results          []*login.ExternalUserInfo
	UserSearchResult *login.ExternalUserInfo
	UserSearchConfig ldap.ServerConfig
	UserSearchError  error
}

var (
	pingResult []*multildap.ServerStatus
	pingError  error
)

func (m *LDAPMock) Ping() ([]*multildap.ServerStatus, error) {
	return pingResult, pingError
}

func (m *LDAPMock) Login(query *login.LoginUserQuery) (*login.ExternalUserInfo, error) {
	return &login.ExternalUserInfo{}, nil
}

func (m *LDAPMock) Users(logins []string) ([]*login.ExternalUserInfo, error) {
	s := []*login.ExternalUserInfo{}
	return s, nil
}

func (m *LDAPMock) User(login string) (*login.ExternalUserInfo, ldap.ServerConfig, error) {
	return m.UserSearchResult, m.UserSearchConfig, m.UserSearchError
}

func setupAPITest(t *testing.T, opts ...func(a *Service)) (*Service, *webtest.Server) {
	t.Helper()
	router := routing.NewRouteRegister()
	cfg := setting.NewCfg()
	cfg.LDAPAuthEnabled = true

	a := ProvideService(cfg,
		router,
		acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
		usertest.NewUserServiceFake(),
		&authinfotest.FakeService{},
		ldap.ProvideGroupsService(),
		&authntest.FakeService{},
		&orgtest.FakeOrgService{},
		service.NewLDAPFakeService(),
		authtest.NewFakeUserAuthTokenService(),
		supportbundlestest.NewFakeBundleService(),
	)

	for _, o := range opts {
		o(a)
	}

	server := webtest.NewServer(t, router)

	return a, server
}

func TestGetUserFromLDAPAPIEndpoint_UserNotFound(t *testing.T) {
	_, server := setupAPITest(t, func(a *Service) {
		a.orgService = &orgtest.FakeOrgService{
			ExpectedOrgs: []*org.OrgDTO{},
		}
		a.ldapService = &service.LDAPFakeService{
			ExpectedClient: &LDAPMock{
				UserSearchResult: nil,
			},
			ExpectedConfig: &ldap.ServersConfig{},
		}
	})

	req := server.NewGetRequest("/api/admin/ldap/user-that-does-not-exist")
	webtest.RequestWithSignedInUser(req, &user.SignedInUser{
		OrgID: 1,
		Permissions: map[int64]map[string][]string{
			1: {"ldap.user:read": {"*"}},
		},
	})

	res, err := server.Send(req)
	defer func() { require.NoError(t, res.Body.Close()) }()
	require.NoError(t, err)

	assert.Equal(t, http.StatusNotFound, res.StatusCode)
	bodyBytes, _ := io.ReadAll(res.Body)
	assert.JSONEq(t, "{\"message\":\"No user was found in the LDAP server(s) with that username\"}", string(bodyBytes))
}

func TestGetUserFromLDAPAPIEndpoint_OrgNotfound(t *testing.T) {
	isAdmin := true
	userSearchResult := &login.ExternalUserInfo{
		Name:           "John Doe",
		Email:          "john.doe@example.com",
		Login:          "johndoe",
		Groups:         []string{"cn=admins,ou=groups,dc=grafana,dc=org"},
		OrgRoles:       map[int64]org.RoleType{1: org.RoleAdmin, 2: org.RoleViewer},
		IsGrafanaAdmin: &isAdmin,
	}

	userSearchConfig := ldap.ServerConfig{
		Attr: ldap.AttributeMap{
			Name:     "ldap-name",
			Surname:  "ldap-surname",
			Email:    "ldap-email",
			Username: "ldap-username",
		},
		Groups: []*ldap.GroupToOrgRole{
			{
				GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org",
				OrgId:   1,
				OrgRole: org.RoleAdmin,
			},
			{
				GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org",
				OrgId:   2,
				OrgRole: org.RoleViewer,
			},
		},
	}

	mockOrgSearchResult := []*org.OrgDTO{
		{ID: 1, Name: "Main Org."},
	}

	_, server := setupAPITest(t, func(a *Service) {
		a.orgService = &orgtest.FakeOrgService{
			ExpectedOrgs: mockOrgSearchResult,
		}
		a.ldapService = &service.LDAPFakeService{
			ExpectedClient: &LDAPMock{
				UserSearchResult: userSearchResult,
				UserSearchConfig: userSearchConfig,
			},
			ExpectedConfig: &ldap.ServersConfig{},
		}
	})

	req := server.NewGetRequest("/api/admin/ldap/johndoe")
	webtest.RequestWithSignedInUser(req, &user.SignedInUser{
		OrgID: 1,
		Permissions: map[int64]map[string][]string{
			1: {"ldap.user:read": {"*"}},
		},
	})

	res, err := server.Send(req)
	defer func() { require.NoError(t, res.Body.Close()) }()
	require.NoError(t, err)

	assert.Equal(t, http.StatusBadRequest, res.StatusCode)
	bodyBytes, _ := io.ReadAll(res.Body)

	var resMap map[string]interface{}
	err = json.Unmarshal(bodyBytes, &resMap)
	assert.NoError(t, err)
	assert.Equal(t, "An organization was not found - Please verify your LDAP configuration", resMap["message"])
}

func TestGetUserFromLDAPAPIEndpoint(t *testing.T) {
	isAdmin := true
	userSearchResult := &login.ExternalUserInfo{
		Name:           "John Doe",
		Email:          "john.doe@example.com",
		Login:          "johndoe",
		Groups:         []string{"cn=admins,ou=groups,dc=grafana,dc=org", "another-group-not-matched"},
		OrgRoles:       map[int64]org.RoleType{1: org.RoleAdmin},
		IsGrafanaAdmin: &isAdmin,
	}

	userSearchConfig := ldap.ServerConfig{
		Attr: ldap.AttributeMap{
			Name:     "ldap-name",
			Surname:  "ldap-surname",
			Email:    "ldap-email",
			Username: "ldap-username",
		},
		Groups: []*ldap.GroupToOrgRole{
			{
				GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org",
				OrgId:   1,
				OrgRole: org.RoleAdmin,
			},
			{
				GroupDN: "cn=admins2,ou=groups,dc=grafana,dc=org",
				OrgId:   1,
				OrgRole: org.RoleAdmin,
			},
		},
	}

	mockOrgSearchResult := []*org.OrgDTO{
		{ID: 1, Name: "Main Org."},
	}

	_, server := setupAPITest(t, func(a *Service) {
		a.orgService = &orgtest.FakeOrgService{
			ExpectedOrgs: mockOrgSearchResult,
		}
		a.ldapService = &service.LDAPFakeService{
			ExpectedClient: &LDAPMock{
				UserSearchResult: userSearchResult,
				UserSearchConfig: userSearchConfig,
			},
			ExpectedConfig: &ldap.ServersConfig{},
		}
	})

	req := server.NewGetRequest("/api/admin/ldap/johndoe")
	webtest.RequestWithSignedInUser(req, &user.SignedInUser{
		OrgID: 1,
		Permissions: map[int64]map[string][]string{
			1: {"ldap.user:read": {"*"}},
		},
	})

	res, err := server.Send(req)
	defer func() { require.NoError(t, res.Body.Close()) }()
	require.NoError(t, err)

	assert.Equal(t, http.StatusOK, res.StatusCode)

	expected := `
		{
		  "name": {
				"cfgAttrValue": "ldap-name", "ldapValue": "John"
			},
			"surname": {
				"cfgAttrValue": "ldap-surname", "ldapValue": "Doe"
			},
			"email": {
				"cfgAttrValue": "ldap-email", "ldapValue": "john.doe@example.com"
			},
			"login": {
				"cfgAttrValue": "ldap-username", "ldapValue": "johndoe"
			},
			"isGrafanaAdmin": true,
			"isDisabled": false,
			"roles": [
				{ "orgId": 1, "orgRole": "Admin", "orgName": "Main Org.", "groupDN": "cn=admins,ou=groups,dc=grafana,dc=org" },
				{ "orgId": 0, "orgRole": "", "orgName": "", "groupDN": "another-group-not-matched" }
			],
			"teams": null
		}
	`

	bodyBytes, _ := io.ReadAll(res.Body)
	assert.JSONEq(t, expected, string(bodyBytes))
}

func TestGetUserFromLDAPAPIEndpoint_WithTeamHandler(t *testing.T) {
	isAdmin := true
	userSearchResult := &login.ExternalUserInfo{
		Name:           "John Doe",
		Email:          "john.doe@example.com",
		Login:          "johndoe",
		Groups:         []string{"cn=admins,ou=groups,dc=grafana,dc=org"},
		OrgRoles:       map[int64]org.RoleType{1: org.RoleAdmin},
		IsGrafanaAdmin: &isAdmin,
	}

	userSearchConfig := ldap.ServerConfig{
		Attr: ldap.AttributeMap{
			Name:     "ldap-name",
			Surname:  "ldap-surname",
			Email:    "ldap-email",
			Username: "ldap-username",
		},
		Groups: []*ldap.GroupToOrgRole{
			{
				GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org",
				OrgId:   1,
				OrgRole: org.RoleAdmin,
			},
		},
	}

	mockOrgSearchResult := []*org.OrgDTO{
		{ID: 1, Name: "Main Org."},
	}

	_, server := setupAPITest(t, func(a *Service) {
		a.orgService = &orgtest.FakeOrgService{
			ExpectedOrgs: mockOrgSearchResult,
		}
		a.ldapService = &service.LDAPFakeService{
			ExpectedClient: &LDAPMock{
				UserSearchResult: userSearchResult,
				UserSearchConfig: userSearchConfig,
			},
			ExpectedConfig: &ldap.ServersConfig{},
		}
	})

	req := server.NewGetRequest("/api/admin/ldap/johndoe")
	webtest.RequestWithSignedInUser(req, &user.SignedInUser{
		OrgID: 1,
		Permissions: map[int64]map[string][]string{
			1: {"ldap.user:read": {"*"}},
		},
	})

	res, err := server.Send(req)
	defer func() { require.NoError(t, res.Body.Close()) }()
	require.NoError(t, err)

	assert.Equal(t, http.StatusOK, res.StatusCode)

	expected := `
		{
		  "name": {
				"cfgAttrValue": "ldap-name", "ldapValue": "John"
			},
			"surname": {
				"cfgAttrValue": "ldap-surname", "ldapValue": "Doe"
			},
			"email": {
				"cfgAttrValue": "ldap-email", "ldapValue": "john.doe@example.com"
			},
			"login": {
				"cfgAttrValue": "ldap-username", "ldapValue": "johndoe"
			},
			"isGrafanaAdmin": true,
			"isDisabled": false,
			"roles": [
				{ "orgId": 1, "orgRole": "Admin", "orgName": "Main Org.", "groupDN": "cn=admins,ou=groups,dc=grafana,dc=org" }
			],
			"teams": null
		}
	`

	bodyBytes, _ := io.ReadAll(res.Body)
	assert.JSONEq(t, expected, string(bodyBytes))
}

func TestGetLDAPStatusAPIEndpoint(t *testing.T) {
	pingResult = []*multildap.ServerStatus{
		{Host: "10.0.0.3", Port: 361, Available: true, Error: nil},
		{Host: "10.0.0.3", Port: 362, Available: true, Error: nil},
		{Host: "10.0.0.5", Port: 361, Available: false, Error: errors.New("something is awfully wrong")},
	}

	_, server := setupAPITest(t, func(a *Service) {
		a.ldapService = &service.LDAPFakeService{
			ExpectedClient: &LDAPMock{},
			ExpectedConfig: &ldap.ServersConfig{},
		}
	})

	req := server.NewGetRequest("/api/admin/ldap/status")
	webtest.RequestWithSignedInUser(req, &user.SignedInUser{
		OrgID: 1,
		Permissions: map[int64]map[string][]string{
			1: {"ldap.status:read": {}},
		},
	})

	res, err := server.Send(req)
	defer func() { require.NoError(t, res.Body.Close()) }()
	require.NoError(t, err)

	assert.Equal(t, http.StatusOK, res.StatusCode)

	expected := `
	[
		{ "host": "10.0.0.3", "port": 361, "available": true, "error": "" },
		{ "host": "10.0.0.3", "port": 362, "available": true, "error": "" },
		{ "host": "10.0.0.5", "port": 361, "available": false, "error": "something is awfully wrong" }
	]
	`

	bodyBytes, _ := io.ReadAll(res.Body)
	assert.JSONEq(t, expected, string(bodyBytes))
}

func TestPostSyncUserWithLDAPAPIEndpoint_Success(t *testing.T) {
	userServiceMock := usertest.NewUserServiceFake()
	userServiceMock.ExpectedUser = &user.User{Login: "ldap-daniel", ID: 34}

	_, server := setupAPITest(t, func(a *Service) {
		a.userService = userServiceMock
		a.ldapService = &service.LDAPFakeService{
			ExpectedClient: &LDAPMock{UserSearchResult: &login.ExternalUserInfo{
				Login: "ldap-daniel",
			}},
			ExpectedConfig: &ldap.ServersConfig{},
		}
	})

	req := server.NewPostRequest("/api/admin/ldap/sync/34", nil)
	webtest.RequestWithSignedInUser(req, &user.SignedInUser{
		OrgID: 1,
		Permissions: map[int64]map[string][]string{
			1: {"ldap.user:sync": {}},
		},
	})

	res, err := server.Send(req)
	defer func() { require.NoError(t, res.Body.Close()) }()
	require.NoError(t, err)

	assert.Equal(t, http.StatusOK, res.StatusCode)

	expected := `
	{
		"message": "User synced successfully"
	}
	`

	bodyBytes, _ := io.ReadAll(res.Body)
	assert.JSONEq(t, expected, string(bodyBytes))
}

func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotFound(t *testing.T) {
	userServiceMock := usertest.NewUserServiceFake()
	userServiceMock.ExpectedError = user.ErrUserNotFound

	_, server := setupAPITest(t, func(a *Service) {
		a.userService = userServiceMock
		a.ldapService = &service.LDAPFakeService{
			ExpectedClient: &LDAPMock{},
			ExpectedConfig: &ldap.ServersConfig{},
		}
	})

	req := server.NewPostRequest("/api/admin/ldap/sync/34", nil)
	webtest.RequestWithSignedInUser(req, &user.SignedInUser{
		OrgID: 1,
		Permissions: map[int64]map[string][]string{
			1: {"ldap.user:sync": {}},
		},
	})

	res, err := server.Send(req)
	defer func() { require.NoError(t, res.Body.Close()) }()
	require.NoError(t, err)

	assert.Equal(t, http.StatusNotFound, res.StatusCode)

	expected := `
	{
		"message": "user not found"
	}
	`

	bodyBytes, _ := io.ReadAll(res.Body)
	assert.JSONEq(t, expected, string(bodyBytes))
}

func TestPostSyncUserWithLDAPAPIEndpoint_WhenGrafanaAdmin(t *testing.T) {
	userServiceMock := usertest.NewUserServiceFake()
	userServiceMock.ExpectedUser = &user.User{Login: "ldap-daniel", ID: 34}

	_, server := setupAPITest(t, func(a *Service) {
		a.userService = userServiceMock
		a.adminUser = "ldap-daniel"
		a.ldapService = &service.LDAPFakeService{
			ExpectedClient: &LDAPMock{UserSearchError: multildap.ErrDidNotFindUser},
			ExpectedConfig: &ldap.ServersConfig{},
		}
	})

	req := server.NewPostRequest("/api/admin/ldap/sync/34", nil)
	webtest.RequestWithSignedInUser(req, &user.SignedInUser{
		OrgID: 1,
		Permissions: map[int64]map[string][]string{
			1: {"ldap.user:sync": {}},
		},
	})

	res, err := server.Send(req)
	defer func() { require.NoError(t, res.Body.Close()) }()
	require.NoError(t, err)

	assert.Equal(t, http.StatusBadRequest, res.StatusCode)

	bodyBytes, _ := io.ReadAll(res.Body)
	var resMap map[string]interface{}
	err = json.Unmarshal(bodyBytes, &resMap)
	assert.NoError(t, err)
	assert.Equal(t, "Refusing to sync grafana super admin \"ldap-daniel\" - it would be disabled", resMap["message"])
}

func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) {
	userServiceMock := usertest.NewUserServiceFake()
	userServiceMock.ExpectedUser = &user.User{Login: "ldap-daniel", ID: 34}

	_, server := setupAPITest(t, func(a *Service) {
		a.userService = userServiceMock
		a.authInfoService = &authinfotest.FakeService{ExpectedExternalUser: &login.ExternalUserInfo{IsDisabled: true, UserId: 34}}
		a.ldapService = &service.LDAPFakeService{
			ExpectedClient: &LDAPMock{UserSearchError: multildap.ErrDidNotFindUser},
			ExpectedConfig: &ldap.ServersConfig{},
		}
	})

	req := server.NewPostRequest("/api/admin/ldap/sync/34", nil)
	webtest.RequestWithSignedInUser(req, &user.SignedInUser{
		OrgID: 1,
		Permissions: map[int64]map[string][]string{
			1: {"ldap.user:sync": {}},
		},
	})

	res, err := server.Send(req)
	defer func() { require.NoError(t, res.Body.Close()) }()
	require.NoError(t, err)

	assert.Equal(t, http.StatusBadRequest, res.StatusCode)

	expected := `
	{
		"message": "User not found in LDAP. Disabled the user without updating information"
	}
	`

	bodyBytes, _ := io.ReadAll(res.Body)
	assert.JSONEq(t, expected, string(bodyBytes))
}

func TestLDAP_AccessControl(t *testing.T) {
	f, errC := os.CreateTemp("", "ldap.toml")
	require.NoError(t, errC)

	_, errF := f.WriteString(
		`[[servers]]
host = "127.0.0.1"
port = 389
search_filter = "(cn=%s)"
search_base_dns = ["dc=grafana,dc=org"]`)
	require.NoError(t, errF)

	ldapConfigFile := f.Name()

	errF = f.Close()
	require.NoError(t, errF)

	type testCase struct {
		desc         string
		method       string
		url          string
		expectedCode int
		permissions  []accesscontrol.Permission
	}
	tests := []testCase{
		{
			url:          "/api/admin/ldap/reload",
			method:       http.MethodPost,
			desc:         "ReloadLDAPCfg should return 200 for user with correct permissions",
			expectedCode: http.StatusOK,
			permissions: []accesscontrol.Permission{
				{Action: accesscontrol.ActionLDAPConfigReload},
			},
		},
		{
			url:          "/api/admin/ldap/reload",
			method:       http.MethodPost,
			desc:         "ReloadLDAPCfg should return 403 for user without required permissions",
			expectedCode: http.StatusForbidden,
			permissions: []accesscontrol.Permission{
				{Action: "wrong"},
			},
		},
		{
			url:          "/api/admin/ldap/status",
			method:       http.MethodGet,
			desc:         "GetLDAPStatus should return 200 for user without required permissions",
			expectedCode: http.StatusOK,
			permissions: []accesscontrol.Permission{
				{Action: accesscontrol.ActionLDAPStatusRead},
			},
		},
		{
			url:          "/api/admin/ldap/status",
			method:       http.MethodGet,
			desc:         "GetLDAPStatus should return 200 for user without required permissions",
			expectedCode: http.StatusForbidden,
			permissions: []accesscontrol.Permission{
				{Action: "wrong"},
			},
		},
		{
			url:          "/api/admin/ldap/test",
			method:       http.MethodGet,
			desc:         "GetUserFromLDAP should return 200 for user with required permissions",
			expectedCode: http.StatusOK,
			permissions: []accesscontrol.Permission{
				{Action: accesscontrol.ActionLDAPUsersRead},
			},
		},
		{
			url:          "/api/admin/ldap/test",
			method:       http.MethodGet,
			desc:         "GetUserFromLDAP should return 403 for user without required permissions",
			expectedCode: http.StatusForbidden,
			permissions: []accesscontrol.Permission{
				{Action: "wrong"},
			},
		},
		{
			url:          "/api/admin/ldap/sync/1",
			method:       http.MethodPost,
			desc:         "PostSyncUserWithLDAP should return 200 for user without required permissions",
			expectedCode: http.StatusOK,
			permissions: []accesscontrol.Permission{
				{Action: accesscontrol.ActionLDAPUsersSync},
			},
		},
		{
			url:          "/api/admin/ldap/sync/1",
			method:       http.MethodPost,
			desc:         "PostSyncUserWithLDAP should return 200 for user without required permissions",
			expectedCode: http.StatusForbidden,
			permissions: []accesscontrol.Permission{
				{Action: "wrong"},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.desc, func(t *testing.T) {
			_, server := setupAPITest(t, func(a *Service) {
				a.userService = &usertest.FakeUserService{ExpectedUser: &user.User{Login: "ldap-daniel", ID: 1}}
				a.cfg.ConfigFilePath = ldapConfigFile
				a.ldapService = &service.LDAPFakeService{
					ExpectedClient: &LDAPMock{UserSearchResult: &login.ExternalUserInfo{
						Login: "ldap-daniel",
					}},
					ExpectedConfig: &ldap.ServersConfig{},
				}
			})
			// Add minimal setup to pass handler
			res, err := server.Send(
				webtest.RequestWithSignedInUser(server.NewRequest(tt.method, tt.url, nil),
					userWithPermissions(1, tt.permissions)))
			require.NoError(t, err)

			bodyBytes, _ := io.ReadAll(res.Body)
			assert.Equal(t, tt.expectedCode, res.StatusCode, string(bodyBytes))
			require.NoError(t, res.Body.Close())
		})
	}
}

func userWithPermissions(orgID int64, permissions []accesscontrol.Permission) *user.SignedInUser {
	return &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByActionContext(context.Background(), permissions)}}
}
