package guardian

import (
	"crypto/tls"
	"fmt"
	"strconv"
	"strings"

	ldapDriver "gopkg.in/ldap.v2"

	"code.justin.tv/systems/guardian/cfg"

	"github.com/derekdowling/go-json-spec-handler"
)

// LDAPIdentifier uses LDAP for identity to perform user/group
// identity lookups.
type LDAPIdentifier struct {
	*ldapDriver.Conn
}

// NewLDAPIdentifier returns an anonymouse-bind LDAPIdentifier
func NewLDAPIdentifier(config *cfg.Config) (*LDAPIdentifier, error) {

	connection, err := connectToLDAP(config)
	if err != nil {
		return nil, err
	}

	return &LDAPIdentifier{connection}, err
}

// Connects to LDAP server at given host&port, using the given tls config.
// Always connects with tls
func connectToLDAP(conf *cfg.Config) (*ldapDriver.Conn, error) {
	tlsConf := &tls.Config{ServerName: conf.LDAP.Address}
	l, err := ldapDriver.DialTLS("tcp", fmt.Sprintf("%s:%d", conf.LDAP.Address, conf.LDAP.Port), tlsConf)
	if err != nil {
		return nil, err
	}
	return l, err
}

// BindToUser connects to ldap as user
func (l *LDAPIdentifier) BindToUser(config *cfg.Config) error {

	cn := fmt.Sprintf("cn=%s,ou=Users,dc=justin,dc=tv", config.LDAP.BindUser)
	return l.Bind(cn, config.LDAP.BindPassword)

}

// AttributeList is a list of attributes that should be returned with a User's
// LDAP account entry
var AttributeList = []string{
	"cn",
	"ds-pwp-account-disabled",
	"gidNumber",
	"givenName",
	"homeDirectory",
	"loginShell",
	"mail",
	"objectClass",
	"sn",
	"sshPublicKey",
	"uid",
	"uidNumber",
	"uniqueMember",
	"description",
	"mail",
	"isMemberOf",
}

// ListUsers returns a list of all LDAP users
func (l *LDAPIdentifier) ListUsers() (users []*User, err error) {

	attr := []string{"uid", "cn", "mail"}
	searchFilter := "(&(objectClass=organizationalPerson)(!(ds-pwp-account-disabled=true)))"

	searchRequest := ldapDriver.NewSearchRequest(
		"ou=Users,dc=justin,dc=tv", // The base dn to search
		ldapDriver.ScopeWholeSubtree, ldapDriver.NeverDerefAliases, 0, 0, false,
		searchFilter, // The filter to apply
		attr,         // A list attributes to retrieve
		nil,
	)

	sr, err := l.Search(searchRequest)
	if err != nil {
		return nil, err
	}

	users = make([]*User, len(sr.Entries))

	for i, entry := range sr.Entries {

		users[i] = &User{
			CN:    entry.GetAttributeValue("cn"),
			UID:   entry.GetAttributeValue("uid"),
			Email: entry.GetAttributeValue("mail"),
		}
	}

	return users, err
}

// ListGroups returns all LDAP groups that match the team idenitifer format.
// AKA "team-*"
func (l *LDAPIdentifier) ListGroups() (groups []*Group, err error) {
	searchRequest := ldapDriver.NewSearchRequest(
		"ou=Groups,dc=justin,dc=tv", // The base dn to search
		ldapDriver.ScopeWholeSubtree, ldapDriver.NeverDerefAliases, 0, 0, false,
		"(|(cn=infra)(&(cn=team-*)(objectClass=groupOfUniqueNames)))", // The filter to apply
		[]string{"cn", "gidNumber", "description"},                    // A list attributes to retrieve
		nil,
	)

	sr, err := l.Search(searchRequest)
	if err != nil {
		return nil, err
	}

	groups = make([]*Group, len(sr.Entries))

	var gidnum int64

	for i, entry := range sr.Entries {
		gidnum, err = getInt64Attr("gidNumber", entry)
		if err != nil {
			return nil, err
		}

		groups[i] = &Group{
			CN:          entry.GetAttributeValue("cn"),
			GID:         gidnum,
			Description: entry.GetAttributeValue("description"),
		}
	}

	return groups, err
}

// GetGroup retrieves a group object based on "cn" identifier.
// DOES NOT limit by what is viewable in ListGroups
func (l *LDAPIdentifier) GetGroup(cn string) (group *Group, err error) {
	searchFilter := fmt.Sprintf("(entryDN=cn=%s,ou=Groups,dc=justin,dc=tv)", cn)

	searchRequest := ldapDriver.NewSearchRequest(
		"ou=Groups,dc=justin,dc=tv", // The base dn to search
		ldapDriver.ScopeWholeSubtree, ldapDriver.NeverDerefAliases, 0, 0, false,
		searchFilter, // The filter to apply
		[]string{"cn", "gidNumber", "description", "uniqueMember"}, // A list attributes to retrieve
		nil,
	)

	sr, err := l.Search(searchRequest)
	if err != nil {
		return nil, err
	}

	if len(sr.Entries) == 0 {
		return nil, nil
	}

	// assuming only 1 entry for a user
	entry := sr.Entries[0]

	gidnum, err := getInt64Attr("gidNumber", entry)
	if err != nil {
		return nil, err
	}

	members := entry.GetAttributeValues("uniqueMember")
	isMemberOfCN(members)
	group = &Group{
		CN:          entry.GetAttributeValue("cn"),
		GID:         gidnum,
		Description: entry.GetAttributeValue("description"),
		Members:     members,
	}

	return group, err
}

// strip dn to only get cn:
// "cn=team-web-reliability-edge,ou=Groups,dc=justin,dc=tv"
// to
// "team-web-reliability-edge"
// NOTE: changes occur in place
func isMemberOfCN(groups []string) {
	for i, dn := range groups {
		cnAll := strings.SplitN(dn, ",", 2)[0]
		cn := strings.SplitN(cnAll, "=", 2)[1]
		groups[i] = cn
	}
}

//newUser maps returned ldap information(ldap.Entry) to a User
func newUser(entry *ldapDriver.Entry) (*User, error) {
	gidnum, err := getInt64Attr("gidNumber", entry)
	if err != nil {
		return nil, err
	}

	uidnum, err := getInt64Attr("uidNumber", entry)
	if err != nil {
		return nil, err
	}

	groups := entry.GetAttributeValues("isMemberOf")
	isMemberOfCN(groups)
	user := &User{
		CN:            entry.GetAttributeValue("cn"),
		UID:           entry.GetAttributeValue("uid"),
		GIDNumber:     gidnum,
		HomeDirectory: entry.GetAttributeValue("homeDirectory"),
		UIDNumber:     uidnum,
		Email:         entry.GetAttributeValue("mail"),
		SSHPubkeys:    entry.GetAttributeValues("sshpublickey"),
		Groups:        groups,
	}

	return user, err
}

// GetUser returns a user object based on "cn" as an identifier.
func (l *LDAPIdentifier) GetUser(cn string) (user *User, err error) {

	sf := fmt.Sprintf("(&(entryDN=cn=%s,ou=Users,dc=justin,dc=tv)(!(ds-pwp-account-disabled=true)))", cn)
	searchRequest := ldapDriver.NewSearchRequest(
		"ou=Users,dc=justin,dc=tv", // The base dn to search
		ldapDriver.ScopeWholeSubtree, ldapDriver.NeverDerefAliases, 0, 0, false,
		sf,            // The filter to apply
		AttributeList, // A list attributes to retrieve
		nil,
	)

	sr, err := l.Search(searchRequest)
	if err != nil {
		return nil, err
	}

	if len(sr.Entries) == 0 {
		return nil, nil
	}
	// assuming only 1 entry for a user
	entry := sr.Entries[0]
	user, err = newUser(entry)
	if err != nil {
		return nil, err
	}
	return user, err
}

// entryFromUID searches LDAP for matching UID, returns an error if the search failed
// or if no results were returned
func (l *LDAPIdentifier) entryFromUID(uid string) (*ldapDriver.Entry, error) {
	sf := fmt.Sprintf("(&(uid=%s)(!(ds-pwp-account-disabled=true)))", uid)

	searchRequest := ldapDriver.NewSearchRequest(
		"ou=Users,dc=justin,dc=tv", // The base dn to search
		ldapDriver.ScopeWholeSubtree, ldapDriver.NeverDerefAliases, 0, 0, false,
		sf,            // The filter to apply
		AttributeList, // A list attributes to retrieve
		nil,
	)

	result, err := l.Search(searchRequest)
	if err != nil {
		return nil, err
	}

	if len(result.Entries) == 0 {
		return nil, jsh.NotFound("user", uid)
	}

	// assuming only 1 entry for a user
	return result.Entries[0], nil
}

// GetUserByName returns a User object for a given username identifier or an error
// if non was found
func (l *LDAPIdentifier) GetUserByName(username string) (*User, error) {
	entry, err := l.entryFromUID(username)
	if err != nil {
		return nil, err
	}

	return newUser(entry)
}

// Authenticate uses a credential set to validate a user's LDAP account existance
func (l *LDAPIdentifier) Authenticate(username, password string) (*User, error) {
	entry, err := l.entryFromUID(username)
	if err != nil {
		return nil, fmt.Errorf("Error finding entry for LDAP UID: %s", err.Error())
	}

	//authenticate user
	dn := entry.DN
	bindRequest := ldapDriver.NewSimpleBindRequest(dn, password, nil)

	_, err = l.SimpleBind(bindRequest)
	if err != nil {
		return nil, fmt.Errorf("Error binding to LDAP account: %s", err.Error())
	}

	user, err := newUser(entry)
	if err != nil {
		return nil, fmt.Errorf("Error creating user from entry: %s", err.Error())
	}

	return user, nil
}

// getInt64attr gets the underlying attr str and type converts,while
// considering empty string as a non-value (0, as per json `omitempty`)
func getInt64Attr(attr string, entry *ldapDriver.Entry) (int64, error) {
	attrVal := entry.GetAttributeValue(attr)
	if attrVal == "" {
		return 0, nil
	}

	attrInt64, err := strconv.ParseInt(attrVal, 10, 64)
	if err != nil {
		return 0, err
	}

	return attrInt64, err
}
