// Go Ldap Api 
// Copyright (c) 2022 yo000 <johan@nosd.in>
//

package main

import (
	"fmt"
	"sync"
	"strings"
	
	"github.com/go-ldap/ldap/v3"
	log "github.com/sirupsen/logrus"
)

type MyLdap struct {
	Conn *ldap.Conn
	Host   string
	User   string
	Pass   string
	BaseDN string
	AuthBaseDN string
}

var (
	mutex sync.Mutex
)

func marshalResultToStrMap(sr *ldap.SearchResult) map[string][]string {
	var res = make(map[string][]string)
	for _, e := range sr.Entries {
		for _, a := range e.Attributes {
			for _, v := range a.Values {
				res[a.Name] = append(res[a.Name], v)
			}
		}
	}

	return res
}

func isStringInArray(strarr []string, searched string) bool {
	for _, s := range strarr {
		if strings.EqualFold(s, searched) {
			return true
		}
	}
	return false
}

func connectLdap(myldap *MyLdap) (*MyLdap, error) {
	var err error
	var conLdap *MyLdap
	
	myldap.Conn, err = ldap.DialURL(myldap.Host)
	if err != nil {
		//log.Errorf("Error dialing LDAP: %v", err)
		return conLdap, err
	}
	
	err = myldap.Conn.Bind(myldap.User, myldap.Pass)
	if err != nil {
		//log.Errorf("Error binding LDAP: %v", err)
		return conLdap, err
	}
	return myldap, err
}

func internalLdapSearch(conLdap *MyLdap, searchReq *ldap.SearchRequest, attempt int) (*ldap.SearchResult, error) {
	var err error
	if conLdap.Conn == nil {
		conLdap, err = connectLdap(conLdap)
		if err != nil {
			return nil, err
		}
	}
	mutex.Lock()
	result, err := conLdap.Conn.Search(searchReq)
	mutex.Unlock()
	// Manage connection errors here
	if err != nil && strings.HasSuffix(err.Error(), "ldap: connection closed") {
		log.Error("LDAP connection closed, retrying")
		mutex.Lock()
		conLdap.Conn.Close()
		conLdap, err = connectLdap(conLdap)
		mutex.Unlock()
		if err != nil {
			return result, err
		} else {
			attempt = attempt + 1
			return internalLdapSearch(conLdap, searchReq, attempt)
		}
	}
	
	return result, err
}

func searchByCn(myldap *MyLdap, baseDn, cn, class, attributes string) (*ldap.SearchResult, error) {
	var filter string
	if false == strings.EqualFold(cn, "ALL") {
		filter = fmt.Sprintf("(cn=%s)", cn)
	} else {
		filter = cn
	}
	return doLdapSearch(myldap, baseDn, false, filter, class, attributes)
}

func searchByDn(myldap *MyLdap, dn, attributes string) (*ldap.SearchResult, error) {
	// We cant search for a full DN, so separate cn and base dn, then remove basedn as doLdapSearch already append it
	filter := strings.Split(dn, ",")[0]
	rem := strings.Split(dn, ",")[1:]
	bdn := strings.Join(rem, ",")
	bdn = strings.Replace(bdn, fmt.Sprintf(",%s", myldap.BaseDN), "", 1)
	return doLdapSearch(myldap, bdn, false, filter, "ALL", "ALL")
}

func doLdapSearch(myldap *MyLdap, baseDn string, baseDnIsAbsolute bool, filter, class, attributes string) (*ldap.SearchResult, error) {
	var fFilter string
	var realBaseDn string
	var realAttributes []string
	
	// Build final search filter
	if strings.EqualFold(class, "ALL") {
		fFilter = fmt.Sprintf("(objectClass=*)")
	} else {
		fFilter = fmt.Sprintf("(objectClass=%s)", class)
	}
	
	if false == strings.EqualFold(filter, "ALL") {
		fFilter = fmt.Sprintf("(&%s(%s))", fFilter, ldap.EscapeFilter(filter))
	}
	
	log.Debugf("LDAP search filter: %s", fFilter)
	
	// Build absolute search base DN from configuration & provided DN (which is relative)
	if strings.EqualFold(baseDn, "ALL") || len(baseDn) == 0 {
		realBaseDn = fmt.Sprintf("%s", myldap.BaseDN)
	} else {
		if len(baseDn) > 0 && baseDnIsAbsolute {
			realBaseDn = fmt.Sprintf("%s", baseDn)
		} else {
			realBaseDn = fmt.Sprintf("%s,%s", baseDn, myldap.BaseDN)
		}
	}
	
	log.Debugf("LDAP search base dn: %s", realBaseDn)
	
	// Build attributes array
	if false == strings.EqualFold(attributes, "ALL") {
		for _, a := range strings.Split(attributes, ",") {
			realAttributes = append(realAttributes, a)
		}
	}
	
	log.Debugf("LDAP search attributes to return (all if empty): %v", realAttributes)
	
	searchReq := ldap.NewSearchRequest(realBaseDn, ldap.ScopeWholeSubtree, 0, 0, 0,
									   false, fFilter, realAttributes, []ldap.Control{})
	
	result, err := internalLdapSearch(myldap, searchReq, 0)
	if err != nil {
		return nil, err
	}
	
	return result, nil
}

func findUserFullDN(myldap *MyLdap, username string) (string, error) {
	filter := fmt.Sprintf("cn=%s", username)
	sr, err := doLdapSearch(myldap, myldap.AuthBaseDN, true, filter, "ALL", "")
	if err != nil {
		return "", err
	}
	if len(sr.Entries) == 0 {
		return "", fmt.Errorf("User not found with %s", filter)
	} else if len(sr.Entries) > 1 {
		return "", fmt.Errorf("More than one object (%d) found with %s", len(sr.Entries), filter)
	}
	
	result := sr.Entries[0].DN
	
	return result, nil
}

func createEntry(myldap *MyLdap, dn string, attributes map[string]interface{}) error {	
	log.Debugf("Creating DN %s with attributes %v", dn, attributes)
	
	addReq := ldap.NewAddRequest(dn, nil)
	
	// Remove dn from map if exists
	delete(attributes, "dn")
	
	//build attributes list 
	for key, val := range attributes {
		var v []string
		
		strval := fmt.Sprintf("%s",val)
		if strings.HasPrefix(strval, "[") && strings.HasSuffix(strval, "]") {
			for _, va := range val.([]interface{}) {
				v = append(v, va.(string))
			}
		} else {
			v = append(v, strval)
		}
		
		addReq.Attribute(key, v)
	}
	
	err := myldap.Conn.Add(addReq)
	return err
}

func updateEntry(myldap *MyLdap, dn string, attributes map[string]interface{}) error {	
	log.Debugf("Updating DN %s with attributes %v", dn, attributes)
	
	// Remove dn from map if exists
	delete(attributes, "dn")
	
	// First get the current object so we can build a list of add, modify, delete attributes
	sr, err := searchByDn(myldap, dn, "ALL")
	if err != nil {
		return err
	}
	if len(sr.Entries) == 0 {
		return fmt.Errorf("Object %s not found", dn)
	}
	
	actualAttrs := marshalResultToStrMap(sr)
	
	// convert attributes to map[string][]string
	var attrsMap = make(map[string][]string)
	for key, val := range attributes {
		var v []string
		
		strval := fmt.Sprintf("%s",val)
		if strings.HasPrefix(strval, "[") && strings.HasSuffix(strval, "]") {
			for _, va := range val.([]interface{}) {
				v = append(v, va.(string))
			}
		} else {
			v = append(v, strval)
		}
		
		attrsMap[key] = v
	}
	
	modReq := ldap.NewModifyRequest(dn, nil)

	// Now compare. We browse required attributes to get modify and add operations
	opnr := 0
	for k, v := range attrsMap {
		fv := 0
		va, found := actualAttrs[k]
		if found {
			for _, cv := range va {
				if isStringInArray(v, cv) {
					fv += 1
				}
			}
			if fv != len(va) {
				modReq.Replace(k, v)
				opnr++
			}
		} else {
			modReq.Add(k, v)
			opnr++
		}
	}
	
	// now browse actual attributes to get delete operations
	for k, v := range actualAttrs {
		_, found := attrsMap[k]
		if false == found {
			modReq.Delete(k, v)
			opnr++
		}
	}
	
	if opnr == 0 {
		return fmt.Errorf("No modification required")
	}
	
	// Finally execute operations
	err = myldap.Conn.Modify(modReq)
	return err
}

func deleteEntry(myldap *MyLdap, dn string) error {	
	log.Debugf("Deleting DN %s", dn)
	
	delReq := ldap.NewDelRequest(dn, nil)
	
	err := myldap.Conn.Del(delReq)
	return err
}
