package ldap

import (
	"context"
	"crypto/tls"
	"fmt"
	"net"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/foxcpp/maddy/framework/config"
	tls2 "github.com/foxcpp/maddy/framework/config/tls"
	"github.com/foxcpp/maddy/framework/container"
	"github.com/foxcpp/maddy/framework/log"
	"github.com/foxcpp/maddy/framework/module"
	"github.com/foxcpp/maddy/framework/module/modules"
	"github.com/go-ldap/ldap/v3"
)

const modName = "auth.ldap"

type Auth struct {
	instName string

	urls           []string
	readBind       func(*ldap.Conn) error
	startls        bool
	tlsCfg         *tls.Config
	dialer         *net.Dialer
	requestTimeout time.Duration

	dnTemplate string
	// or
	baseDN         string
	filterTemplate string

	conn     *ldap.Conn
	connLock sync.Mutex

	log *log.Logger
}

func New(c *container.C, modName, instName string) (module.Module, error) {
	return &Auth{
		instName: instName,
		log:      c.DefaultLogger.Sublogger(modName),
	}, nil
}

func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
	a.urls = inlineArgs

	a.dialer = &net.Dialer{}

	cfg.Bool("debug", true, false, &a.log.Debug)
	cfg.Custom("tls_client", true, false, func() (interface{}, error) {
		return &tls.Config{}, nil
	}, tls2.TLSClientBlock, &a.tlsCfg)
	cfg.Callback("urls", func(m *config.Map, node config.Node) error {
		a.urls = append(a.urls, node.Args...)
		return nil
	})
	cfg.Custom("bind", false, false, func() (interface{}, error) {
		return func(*ldap.Conn) error {
			return nil
		}, nil
	}, readBindDirective, &a.readBind)
	cfg.Bool("starttls", false, false, &a.startls)
	cfg.Duration("connect_timeout", false, false, time.Minute, &a.dialer.Timeout)
	cfg.Duration("request_timeout", false, false, time.Minute, &a.requestTimeout)
	cfg.String("dn_template", false, false, "", &a.dnTemplate)
	cfg.String("base_dn", false, false, "", &a.baseDN)
	cfg.String("filter", false, false, "", &a.filterTemplate)
	if _, err := cfg.Process(); err != nil {
		return err
	}

	if a.dnTemplate == "" {
		if a.baseDN == "" {
			return fmt.Errorf("auth.ldap: base_dn not set")
		}
		if a.filterTemplate == "" {
			return fmt.Errorf("auth.ldap: filter not set")
		}
	} else {
		if a.baseDN != "" || a.filterTemplate != "" {
			return fmt.Errorf("auth.ldap: search directives set when dn_template is used")
		}
	}

	return nil
}

func readBindDirective(c *config.Map, n config.Node) (interface{}, error) {
	if len(n.Args) == 0 {
		return nil, fmt.Errorf("auth.ldap: auth expects at least one argument")
	}
	switch n.Args[0] {
	case "off":
		return func(*ldap.Conn) error { return nil }, nil
	case "unauth":
		if len(n.Args) == 2 {
			return func(c *ldap.Conn) error {
				return c.UnauthenticatedBind(n.Args[1])
			}, nil
		}
		return func(c *ldap.Conn) error {
			return c.UnauthenticatedBind("")
		}, nil
	case "plain":
		if len(n.Args) != 3 {
			return nil, fmt.Errorf("auth.ldap: username and password expected for plaintext bind")
		}
		return func(c *ldap.Conn) error {
			return c.Bind(n.Args[1], n.Args[2])
		}, nil
	case "external":
		return (*ldap.Conn).ExternalBind, nil
	}
	return nil, fmt.Errorf("auth.ldap: unknown bind authentication: %v", n.Args[0])
}

func (a *Auth) Name() string {
	return modName
}

func (a *Auth) InstanceName() string {
	return a.instName
}

func (a *Auth) newConn() (*ldap.Conn, error) {
	var (
		conn   *ldap.Conn
		tlsCfg *tls.Config
	)
	for _, u := range a.urls {
		parsedURL, err := url.Parse(u)
		if err != nil {
			return nil, fmt.Errorf("auth.ldap: invalid server URL: %w", err)
		}
		hostname := parsedURL.Host
		a.tlsCfg.ServerName = strings.Split(hostname, ":")[0]
		tlsCfg = a.tlsCfg.Clone()

		conn, err = ldap.DialURL(u, ldap.DialWithDialer(a.dialer), ldap.DialWithTLSConfig(tlsCfg))
		if err != nil {
			a.log.Error("cannot contact directory server", err, "url", u)
			continue
		}
		break
	}
	if conn == nil {
		return nil, fmt.Errorf("auth.ldap: all directory servers are unreachable")
	}

	if a.requestTimeout != 0 {
		conn.SetTimeout(a.requestTimeout)
	}

	if a.startls {
		if err := conn.StartTLS(tlsCfg); err != nil {
			return nil, fmt.Errorf("auth.ldap: %w", err)
		}
	}

	if err := a.readBind(conn); err != nil {
		return nil, fmt.Errorf("auth.ldap: %w", err)
	}

	return conn, nil
}

func (a *Auth) getConn() (*ldap.Conn, error) {
	a.connLock.Lock()
	if a.conn == nil {
		conn, err := a.newConn()
		if err != nil {
			a.connLock.Unlock()
			return nil, err
		}
		a.conn = conn
	}
	if a.conn.IsClosing() {
		if err := a.conn.Close(); err != nil {
			a.log.Error("Connection close failed", err)
		}
		conn, err := a.newConn()
		if err != nil {
			a.connLock.Unlock()
			return nil, err
		}
		a.conn = conn
	}
	return a.conn, nil
}

func (a *Auth) returnConn(conn *ldap.Conn) {
	defer a.connLock.Unlock()
	if err := a.readBind(conn); err != nil {
		a.log.Error("failed to rebind for reading", err)
		if err := a.conn.Close(); err != nil {
			a.log.Error("Connection close failed", err)
		}
		a.conn = nil
	}
	if a.conn != conn {
		if err := a.conn.Close(); err != nil {
			a.log.Error("Connection close failed", err)
		}
	}
	a.conn = conn
}

func (a *Auth) Lookup(_ context.Context, username string) (string, bool, error) {
	conn, err := a.getConn()
	if err != nil {
		return "", false, err
	}
	defer a.returnConn(conn)

	var userDN string
	if a.dnTemplate != "" {
		return "", false, fmt.Errorf("auth.ldap: lookups require search config but dn_template is used")
	} else {
		req := ldap.NewSearchRequest(
			a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
			2, 0, false,
			strings.ReplaceAll(a.filterTemplate, "{username}", ldap.EscapeFilter(username)),
			[]string{"dn"}, nil)
		res, err := conn.Search(req)
		if err != nil {
			return "", false, fmt.Errorf("auth.ldap: search: %w", err)
		}
		if len(res.Entries) > 1 {
			return "", false, fmt.Errorf("auth.ldap: too manu entries returned (%d)", len(res.Entries))
		}
		if len(res.Entries) == 0 {
			return "", false, nil
		}
		userDN = res.Entries[0].DN
	}

	return userDN, true, nil
}

func (a *Auth) AuthPlain(username, password string) error {
	conn, err := a.getConn()
	if err != nil {
		return err
	}
	defer a.returnConn(conn)

	var userDN string
	if a.dnTemplate != "" {
		userDN = strings.ReplaceAll(a.dnTemplate, "{username}", ldap.EscapeDN(username))
	} else {
		req := ldap.NewSearchRequest(
			a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
			2, 0, false,
			strings.ReplaceAll(a.filterTemplate, "{username}", ldap.EscapeFilter(username)),
			[]string{"dn"}, nil)
		res, err := conn.Search(req)
		if err != nil {
			return fmt.Errorf("auth.ldap: search: %w", err)
		}
		if len(res.Entries) > 1 {
			return fmt.Errorf("auth.ldap: too manu entries returned (%d)", len(res.Entries))
		}
		if len(res.Entries) == 0 {
			return module.ErrUnknownCredentials
		}
		userDN = res.Entries[0].DN
	}

	if err := conn.Bind(userDN, password); err != nil {
		return module.ErrUnknownCredentials
	}

	return nil
}

func (a *Auth) Start() error {
	var err error
	a.conn, err = a.newConn()
	if err != nil {
		return fmt.Errorf("auth.ldap: %w", err)
	}
	return nil
}

func (a *Auth) Stop() error {
	a.connLock.Lock()
	defer a.connLock.Unlock()
	return a.conn.Close()
}

func init() {
	var _ module.PlainAuth = &Auth{}
	var _ module.Table = &Auth{}
	modules.Register(modName, New)
	modules.Register("table.ldap", New)
}
