// Package ldapauth validates a username/password pair against an LDAP directory
// using the standard "search then bind" pattern, exactly as an SSSD/LDAP client
// would: connect to the directory, find the user's entry under the configured
// search base, then attempt a bind as that entry's DN with the supplied password.
//
// It is a pure-Go alternative to the PAM backend (internal/pamauth): because it
// speaks LDAP over the wire with no cgo, uopi keeps its fully-static
// (CGO_ENABLED=0) release binary while still authenticating against the same
// directory the host logs in with. It feeds the same HTTP Basic pipeline
// (internal/server/basicauth.go) as PAM.
//
// Unlike PAM it only verifies the password; it does not run the rest of the PAM
// stack (account expiry, access.conf, MFA). For a monitoring HMI that is normally
// sufficient.
package ldapauth

import (
	"crypto/tls"
	"crypto/x509"
	"errors"
	"fmt"
	"os"
	"time"

	"github.com/go-ldap/ldap/v3"
)

// Config configures the LDAP authenticator. URIs and SearchBase are required; the
// remaining fields mirror SSSD defaults so an unconfigured directory (anonymous
// search, RFC2307 schema) works out of the box.
type Config struct {
	// URIs are the directory endpoints, tried in order until one connects, e.g.
	// "ldaps://ldap.example.com" or "ldap://ldap.example.com". Mirrors SSSD's
	// ldap_uri.
	URIs []string
	// SearchBase is the subtree under which user entries are searched. Mirrors
	// SSSD's ldap_search_base.
	SearchBase string
	// UserAttr is the attribute matched against the login name. Empty defaults to
	// "uid" (SSSD's ldap_user_name default for RFC2307).
	UserAttr string
	// UserObjectClass restricts the search to this objectClass. Empty defaults to
	// "posixAccount" (SSSD's ldap_user_object_class default).
	UserObjectClass string
	// BindDN / BindPassword optionally authenticate the *search* (service
	// account). Empty BindDN performs an anonymous search, matching a directory
	// configured without ldap_default_bind_dn.
	BindDN       string
	BindPassword string
	// StartTLS upgrades a plain ldap:// connection to TLS before any credentials
	// are sent. Ignored for ldaps:// (already TLS).
	StartTLS bool
	// CACertFile is an optional PEM file of CA certs to trust for the TLS
	// connection (for a directory using a private CA).
	CACertFile string
	// InsecureSkipVerify disables TLS certificate verification. Insecure; use only
	// for testing against a self-signed directory.
	InsecureSkipVerify bool
	// Timeout bounds each connection attempt. Zero defaults to 10s.
	Timeout time.Duration
}

// ErrInvalidCredentials is returned when the directory rejects the user's bind.
var ErrInvalidCredentials = errors.New("ldap: invalid credentials")

// Authenticator validates credentials against a fixed directory configuration.
// It is safe for concurrent use: each Authenticate call opens and closes its own
// connection.
type Authenticator struct {
	cfg       Config
	tlsConfig *tls.Config
}

// New validates cfg and returns an Authenticator. It fails fast on missing
// required fields or an unreadable CA file so misconfiguration surfaces at
// startup rather than on the first login.
func New(cfg Config) (*Authenticator, error) {
	if len(cfg.URIs) == 0 {
		return nil, errors.New("ldap: at least one uri is required")
	}
	if cfg.SearchBase == "" {
		return nil, errors.New("ldap: search_base is required")
	}
	if cfg.UserAttr == "" {
		cfg.UserAttr = "uid"
	}
	if cfg.UserObjectClass == "" {
		cfg.UserObjectClass = "posixAccount"
	}
	if cfg.Timeout <= 0 {
		cfg.Timeout = 10 * time.Second
	}

	tlsConfig := &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}
	if cfg.CACertFile != "" {
		pem, err := os.ReadFile(cfg.CACertFile)
		if err != nil {
			return nil, fmt.Errorf("ldap: reading ca_cert: %w", err)
		}
		pool := x509.NewCertPool()
		if !pool.AppendCertsFromPEM(pem) {
			return nil, fmt.Errorf("ldap: ca_cert %q contained no certificates", cfg.CACertFile)
		}
		tlsConfig.RootCAs = pool
	}

	return &Authenticator{cfg: cfg, tlsConfig: tlsConfig}, nil
}

// Authenticate verifies username/password against the directory. It returns nil
// on success, ErrInvalidCredentials when the directory rejects the bind, or
// another error on connection/search failure.
func (a *Authenticator) Authenticate(username, password string) error {
	// A bind with a non-empty DN but an empty password is an "unauthenticated
	// bind" that many servers accept as success — which would let anyone in with a
	// blank password. Reject empty passwords before we ever bind.
	if password == "" {
		return ErrInvalidCredentials
	}

	conn, err := a.dial()
	if err != nil {
		return err
	}
	defer conn.Close()

	// Bind for the search: service account if configured, else anonymous.
	if a.cfg.BindDN != "" {
		if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPassword); err != nil {
			return fmt.Errorf("ldap: search bind failed: %w", err)
		}
	}

	// Locate the user's entry. The login name is escaped to prevent LDAP filter
	// injection.
	filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))",
		ldap.EscapeFilter(a.cfg.UserObjectClass),
		a.cfg.UserAttr,
		ldap.EscapeFilter(username))
	req := ldap.NewSearchRequest(
		a.cfg.SearchBase,
		ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
		2, int(a.cfg.Timeout.Seconds()), false,
		filter,
		[]string{"dn"}, nil,
	)
	res, err := conn.Search(req)
	if err != nil {
		return fmt.Errorf("ldap: search failed: %w", err)
	}
	if len(res.Entries) == 0 {
		return ErrInvalidCredentials // unknown user — do not distinguish from bad password
	}
	if len(res.Entries) > 1 {
		return fmt.Errorf("ldap: %q matched %d entries; refusing ambiguous bind", username, len(res.Entries))
	}
	userDN := res.Entries[0].DN

	// Verify the password by binding as the user. Use a fresh connection so the
	// search identity is fully dropped first.
	userConn, err := a.dial()
	if err != nil {
		return err
	}
	defer userConn.Close()
	if err := userConn.Bind(userDN, password); err != nil {
		if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
			return ErrInvalidCredentials
		}
		return fmt.Errorf("ldap: user bind failed: %w", err)
	}
	return nil
}

// dial connects to the first reachable URI and applies StartTLS when requested.
func (a *Authenticator) dial() (*ldap.Conn, error) {
	var lastErr error
	for _, uri := range a.cfg.URIs {
		conn, err := ldap.DialURL(uri, ldap.DialWithTLSConfig(a.tlsConfig))
		if err != nil {
			lastErr = err
			continue
		}
		conn.SetTimeout(a.cfg.Timeout)
		if a.cfg.StartTLS {
			if err := conn.StartTLS(a.tlsConfig); err != nil {
				conn.Close()
				lastErr = fmt.Errorf("ldap: starttls on %q: %w", uri, err)
				continue
			}
		}
		return conn, nil
	}
	return nil, fmt.Errorf("ldap: could not connect to any uri: %w", lastErr)
}
