package main

import (
	"crypto/tls"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

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

type ldapConfig struct {
	Host            string `json:"host"`
	Port            int    `json:"port"`
	UseLdaps        bool   `json:"useLdaps"`
	SkipTlsVerify   bool   `json:"skipTlsVerify"`
	Domain          string `json:"domain"`
	BaseDn          string `json:"baseDn"`
	BindDn          string `json:"bindDn"`
	BindPassword    string `json:"bindPassword"`
	UserFilter      string `json:"userFilter"`
	UsernameAttr    string `json:"usernameAttr"`
	EmailAttr       string `json:"emailAttr"`
	DisplayNameAttr string `json:"displayNameAttr"`
}

type ldapTarget struct {
	host     string
	port     int
	useLdaps bool
}

type authenticateRequest struct {
	Config   ldapConfig `json:"config"`
	Username string     `json:"username"`
	Password string     `json:"password"`
}

type authenticateResponse struct {
	Success     bool   `json:"success"`
	Dn          string `json:"dn,omitempty"`
	Username    string `json:"username,omitempty"`
	Email       string `json:"email,omitempty"`
	DisplayName string `json:"displayName,omitempty"`
	Error       string `json:"error,omitempty"`
}

func env(key, fallback string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return fallback
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

func resolvePort(port int, useLdaps bool) int {
	if useLdaps {
		if port == 640 || port == 389 || port == 0 {
			return 636
		}
		return port
	}
	if port == 0 {
		return 389
	}
	return port
}

func parseTargets(cfg ldapConfig) ([]ldapTarget, error) {
	raw := strings.TrimSpace(cfg.Host)
	if raw == "" {
		return nil, errors.New("не указан LDAP host")
	}

	parts := strings.Split(raw, ",")
	targets := make([]ldapTarget, 0, len(parts))
	for _, part := range parts {
		part = strings.TrimSpace(part)
		if part == "" {
			continue
		}

		useLdaps := cfg.UseLdaps
		host := part
		port := cfg.Port

		if strings.Contains(part, "://") {
			if strings.HasPrefix(strings.ToLower(part), "ldaps://") {
				useLdaps = true
			}
			if strings.HasPrefix(strings.ToLower(part), "ldap://") {
				useLdaps = false
			}
			withoutScheme := part
			if idx := strings.Index(withoutScheme, "://"); idx >= 0 {
				withoutScheme = withoutScheme[idx+3:]
			}
			if slash := strings.Index(withoutScheme, "/"); slash >= 0 {
				withoutScheme = withoutScheme[:slash]
			}
			if colon := strings.LastIndex(withoutScheme, ":"); colon > 0 && colon < len(withoutScheme)-1 {
				host = withoutScheme[:colon]
				var parsedPort int
				_, scanErr := fmt.Sscanf(withoutScheme[colon+1:], "%d", &parsedPort)
				if scanErr == nil && parsedPort > 0 {
					port = parsedPort
				}
			} else {
				host = withoutScheme
			}
		}

		host = strings.TrimSpace(host)
		if host == "" {
			continue
		}
		targets = append(targets, ldapTarget{
			host:     host,
			port:     resolvePort(port, useLdaps),
			useLdaps: useLdaps,
		})
	}

	if len(targets) == 0 {
		return nil, errors.New("не указан LDAP host")
	}
	return targets, nil
}

func connectTarget(target ldapTarget, skipTlsVerify bool) (*ldap.Conn, error) {
	resolvedHost, err := resolveHostAddress(target.host)
	if err != nil {
		return nil, fmt.Errorf("DNS (%s): %w", target.host, err)
	}

	address := fmt.Sprintf("%s:%d", resolvedHost, target.port)
	if target.useLdaps {
		tlsConfig := &tls.Config{
			ServerName:         target.host,
			InsecureSkipVerify: skipTlsVerify,
		}
		return ldap.DialURL(fmt.Sprintf("ldaps://%s", address), ldap.DialWithTLSConfig(tlsConfig))
	}
	return ldap.DialURL(fmt.Sprintf("ldap://%s", address))
}

func connect(cfg ldapConfig) (*ldap.Conn, error) {
	targets, err := parseTargets(cfg)
	if err != nil {
		return nil, err
	}

	var lastErr error
	for _, target := range targets {
		conn, dialErr := connectTarget(target, cfg.SkipTlsVerify)
		if dialErr == nil {
			return conn, nil
		}
		lastErr = fmt.Errorf("%s:%d: %w", target.host, target.port, dialErr)
	}

	if lastErr != nil {
		if strings.Contains(lastErr.Error(), "connection refused") {
			return nil, fmt.Errorf("не удалось подключиться к LDAP (connection refused). Проверьте host, порт (636 для LDAPS) и доступность контейнера ldap-auth до AD: %w", lastErr)
		}
		if strings.Contains(lastErr.Error(), "server misbehaving") || strings.Contains(lastErr.Error(), "no such host") || strings.Contains(lastErr.Error(), "lookup ") || strings.Contains(lastErr.Error(), "DNS (") {
			return nil, fmt.Errorf("не удалось разрешить имя LDAP-сервера (DNS). Добавьте в .env: LDAP_EXTRA_HOSTS=DC-1.mvkug.local:<IP> и LDAP_DNS_SERVERS=<IP>, затем пересоздайте ldap-auth: %w", lastErr)
		}
		return nil, fmt.Errorf("не удалось подключиться к LDAP: %w", lastErr)
	}
	return nil, errors.New("не удалось подключиться к LDAP")
}

func resolveBindDN(cfg ldapConfig) string {
	bindDn := strings.TrimSpace(cfg.BindDn)
	if bindDn == "" {
		return bindDn
	}
	if strings.Contains(bindDn, "=") || strings.Contains(bindDn, `\`) || strings.Contains(bindDn, "@") {
		return bindDn
	}
	domain := strings.TrimSpace(cfg.Domain)
	if domain != "" {
		return bindDn + "@" + domain
	}
	return bindDn
}

func normalizeUserFilter(filter string) string {
	filter = strings.ReplaceAll(filter, "{{username}}", "{username}")
	filter = strings.ReplaceAll(filter, "{user}", "{username}")
	filter = strings.TrimSpace(filter)
	for strings.HasPrefix(filter, "((") && strings.HasSuffix(filter, "))") && len(filter) > 4 {
		filter = filter[1 : len(filter)-1]
	}
	return filter
}

func fetchDefaultNamingContext(conn *ldap.Conn) string {
	request := ldap.NewSearchRequest(
		"",
		ldap.ScopeBaseObject,
		ldap.NeverDerefAliases,
		0,
		0,
		false,
		"(objectClass=*)",
		[]string{"defaultNamingContext"},
		nil,
	)
	result, err := conn.Search(request)
	if err != nil || len(result.Entries) == 0 {
		return ""
	}
	return strings.TrimSpace(result.Entries[0].GetAttributeValue("defaultNamingContext"))
}

func isNoSuchObject(err error) bool {
	ldapErr, ok := err.(*ldap.Error)
	return ok && ldapErr.ResultCode == 32
}

func swapRdnPrefix(baseDn, fromPrefix, toPrefix string) string {
	upper := strings.ToUpper(baseDn)
	fromUpper := strings.ToUpper(fromPrefix)
	if !strings.HasPrefix(upper, fromUpper) {
		return ""
	}
	rest := baseDn[len(fromPrefix):]
	comma := strings.Index(rest, ",")
	if comma < 0 {
		return ""
	}
	name := strings.TrimSpace(rest[:comma])
	suffix := strings.TrimSpace(rest[comma+1:])
	if name == "" || suffix == "" {
		return ""
	}
	return toPrefix + name + "," + suffix
}

func buildSearchBaseCandidates(configured string, defaultNamingContext string) []string {
	configured = strings.TrimSpace(configured)
	seen := map[string]struct{}{}
	out := make([]string, 0, 4)

	add := func(dn string) {
		dn = strings.TrimSpace(dn)
		if dn == "" {
			return
		}
		key := strings.ToLower(dn)
		if _, exists := seen[key]; exists {
			return
		}
		seen[key] = struct{}{}
		out = append(out, dn)
	}

	add(configured)
	add(swapRdnPrefix(configured, "CN=", "OU="))
	add(swapRdnPrefix(configured, "OU=", "CN="))
	add(defaultNamingContext)
	return out
}

func searchUser(conn *ldap.Conn, baseDn, filter string, attrs []string) (*ldap.SearchResult, error) {
	request := ldap.NewSearchRequest(
		baseDn,
		ldap.ScopeWholeSubtree,
		ldap.NeverDerefAliases,
		1,
		15,
		false,
		filter,
		attrs,
		nil,
	)
	return conn.Search(request)
}

func findMatchingContainers(conn *ldap.Conn, domainRoot, nameHint string) []string {
	nameHint = strings.TrimSpace(nameHint)
	if domainRoot == "" || nameHint == "" {
		return nil
	}

	escaped := ldap.EscapeFilter(nameHint)
	filter := fmt.Sprintf("(|(ou=%s)(cn=%s)(name=%s))", escaped, escaped, escaped)
	result, err := conn.Search(ldap.NewSearchRequest(
		domainRoot,
		ldap.ScopeWholeSubtree,
		ldap.NeverDerefAliases,
		5,
		10,
		false,
		filter,
		[]string{"dn"},
		nil,
	))
	if err != nil || len(result.Entries) == 0 {
		return nil
	}

	dns := make([]string, 0, len(result.Entries))
	for _, entry := range result.Entries {
		dns = append(dns, entry.DN)
	}
	return dns
}

func extractNameHint(baseDn string) string {
	parts := strings.Split(baseDn, ",")
	if len(parts) == 0 {
		return ""
	}
	first := strings.TrimSpace(parts[0])
	if eq := strings.Index(first, "="); eq > 0 {
		return strings.TrimSpace(first[eq+1:])
	}
	return ""
}

func formatSearchError(baseDn string, defaultNamingContext string, alternatives []string, err error) error {
	if isNoSuchObject(err) {
		hint := fmt.Sprintf("Base DN «%s» не существует в Active Directory", baseDn)
		if ouVariant := swapRdnPrefix(baseDn, "CN=", "OU="); ouVariant != "" && !strings.EqualFold(ouVariant, baseDn) {
			hint += fmt.Sprintf(". Попробуйте OU вместо CN: %s", ouVariant)
		}
		if defaultNamingContext != "" {
			hint += fmt.Sprintf(". Корень домена: %s", defaultNamingContext)
		}
		if len(alternatives) > 0 {
			hint += fmt.Sprintf(". Найдены похожие контейнеры: %s", strings.Join(alternatives, "; "))
		} else {
			hint += ". Укажите OU или контейнер, где находятся пользователи"
		}
		return errors.New(hint)
	}
	return fmt.Errorf("ошибка поиска пользователя в «%s»: %w", baseDn, err)
}

type discoverResponse struct {
	Success              bool     `json:"success"`
	DomainRoot           string   `json:"domainRoot,omitempty"`
	ConfiguredBaseDn     string   `json:"configuredBaseDn,omitempty"`
	SuggestedBaseDn      string   `json:"suggestedBaseDn,omitempty"`
	TopLevelContainers   []string `json:"topLevelContainers,omitempty"`
	MatchingContainers   []string `json:"matchingContainers,omitempty"`
	SearchBaseCandidates []string `json:"searchBaseCandidates,omitempty"`
	Error                string   `json:"error,omitempty"`
}

func discover(cfg ldapConfig) (discoverResponse, error) {
	if strings.TrimSpace(cfg.BaseDn) == "" {
		return discoverResponse{}, errors.New("не указан Base DN")
	}
	bindDn := resolveBindDN(cfg)
	if bindDn == "" {
		return discoverResponse{}, errors.New("не указан Bind DN")
	}

	conn, err := connect(cfg)
	if err != nil {
		return discoverResponse{}, err
	}
	defer conn.Close()

	if err := conn.Bind(bindDn, cfg.BindPassword); err != nil {
		return discoverResponse{}, fmt.Errorf("ошибка bind сервисной учётной записи (%s): %w", bindDn, err)
	}

	domainRoot := fetchDefaultNamingContext(conn)
	nameHint := extractNameHint(cfg.BaseDn)
	candidates := buildSearchBaseCandidates(cfg.BaseDn, domainRoot)
	matching := findMatchingContainers(conn, domainRoot, nameHint)

	topLevel := make([]string, 0, 16)
	if domainRoot != "" {
		result, searchErr := conn.Search(ldap.NewSearchRequest(
			domainRoot,
			ldap.ScopeSingleLevel,
			ldap.NeverDerefAliases,
			0,
			10,
			false,
			"(|(objectClass=organizationalUnit)(objectClass=container)(objectClass=builtinDomain))",
			[]string{"dn", "name", "ou"},
			nil,
		))
		if searchErr == nil {
			for _, entry := range result.Entries {
				topLevel = append(topLevel, entry.DN)
			}
		}
	}

	suggested := cfg.BaseDn
	if len(matching) > 0 {
		suggested = matching[0]
	} else if ouVariant := swapRdnPrefix(cfg.BaseDn, "CN=", "OU="); ouVariant != "" {
		suggested = ouVariant
	} else if domainRoot != "" {
		suggested = domainRoot
	}

	return discoverResponse{
		Success:              true,
		DomainRoot:           domainRoot,
		ConfiguredBaseDn:     cfg.BaseDn,
		SuggestedBaseDn:      suggested,
		TopLevelContainers:   topLevel,
		MatchingContainers:   matching,
		SearchBaseCandidates: candidates,
	}, nil
}

func firstAttr(entry *ldap.Entry, names ...string) string {
	for _, name := range names {
		if name == "" {
			continue
		}
		values := entry.GetAttributeValues(name)
		if len(values) > 0 && strings.TrimSpace(values[0]) != "" {
			return strings.TrimSpace(values[0])
		}
	}
	return ""
}

func authenticate(cfg ldapConfig, username, password string) (authenticateResponse, error) {
	username = strings.TrimSpace(username)
	password = strings.TrimSpace(password)
	if username == "" || password == "" {
		return authenticateResponse{}, errors.New("укажите логин и пароль")
	}
	if strings.TrimSpace(cfg.BaseDn) == "" {
		return authenticateResponse{}, errors.New("не указан Base DN")
	}
	bindDn := resolveBindDN(cfg)
	if bindDn == "" {
		return authenticateResponse{}, errors.New("не указан Bind DN")
	}

	filterTemplate := normalizeUserFilter(cfg.UserFilter)
	if filterTemplate == "" {
		filterTemplate = "(|(sAMAccountName={username})(userPrincipalName={username})(mail={username})(uid={username}))"
	}
	filter := strings.ReplaceAll(filterTemplate, "{username}", ldap.EscapeFilter(username))

	conn, err := connect(cfg)
	if err != nil {
		return authenticateResponse{}, err
	}
	defer conn.Close()

	if err := conn.Bind(bindDn, cfg.BindPassword); err != nil {
		return authenticateResponse{}, fmt.Errorf("ошибка bind сервисной учётной записи (%s): %w", bindDn, err)
	}

	defaultNamingContext := fetchDefaultNamingContext(conn)
	searchAttrs := []string{"dn", cfg.UsernameAttr, cfg.EmailAttr, cfg.DisplayNameAttr, "cn", "mail", "uid", "sAMAccountName", "userPrincipalName"}
	searchBases := buildSearchBaseCandidates(cfg.BaseDn, defaultNamingContext)

	var entry *ldap.Entry
	var lastSearchErr error
	var lastBaseDn string
	for _, baseDn := range searchBases {
		result, searchErr := searchUser(conn, baseDn, filter, searchAttrs)
		if searchErr != nil {
			lastSearchErr = searchErr
			lastBaseDn = baseDn
			if isNoSuchObject(searchErr) {
				continue
			}
			return authenticateResponse{}, formatSearchError(baseDn, defaultNamingContext, nil, searchErr)
		}
		if len(result.Entries) > 0 {
			entry = result.Entries[0]
			break
		}
	}

	if entry == nil {
		alternatives := findMatchingContainers(conn, defaultNamingContext, extractNameHint(cfg.BaseDn))
		if lastSearchErr != nil {
			return authenticateResponse{}, formatSearchError(lastBaseDn, defaultNamingContext, alternatives, lastSearchErr)
		}
		hint := fmt.Sprintf("пользователь «%s» не найден в LDAP", username)
		if len(alternatives) > 0 {
			hint += fmt.Sprintf(". Проверьте Base DN — возможные контейнеры: %s", strings.Join(alternatives, "; "))
		} else {
			hint += fmt.Sprintf(". Проверьте Base DN (%s) и фильтр поиска", cfg.BaseDn)
		}
		return authenticateResponse{}, errors.New(hint)
	}
	userDn := entry.DN

	if err := conn.Bind(userDn, password); err != nil {
		return authenticateResponse{}, errors.New("неверный логин или пароль LDAP")
	}

	usernameAttr := cfg.UsernameAttr
	if usernameAttr == "" {
		usernameAttr = "sAMAccountName"
	}
	emailAttr := cfg.EmailAttr
	if emailAttr == "" {
		emailAttr = "mail"
	}
	displayNameAttr := cfg.DisplayNameAttr
	if displayNameAttr == "" {
		displayNameAttr = "displayName"
	}

	resolvedUsername := firstAttr(entry, usernameAttr, "sAMAccountName", "uid", "userPrincipalName")
	if resolvedUsername == "" {
		resolvedUsername = username
	}
	email := firstAttr(entry, emailAttr, "mail", "userPrincipalName")
	displayName := firstAttr(entry, displayNameAttr, "cn", "displayName")
	if displayName == "" {
		displayName = resolvedUsername
	}

	return authenticateResponse{
		Success:     true,
		Dn:          userDn,
		Username:    resolvedUsername,
		Email:       email,
		DisplayName: displayName,
	}, nil
}

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})

	mux.HandleFunc("/discover", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			writeJSON(w, http.StatusMethodNotAllowed, discoverResponse{Success: false, Error: "Метод не поддерживается"})
			return
		}

		var req authenticateRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeJSON(w, http.StatusBadRequest, discoverResponse{Success: false, Error: "Некорректное тело запроса"})
			return
		}

		result, err := discover(req.Config)
		if err != nil {
			writeJSON(w, http.StatusBadRequest, discoverResponse{Success: false, Error: err.Error()})
			return
		}
		writeJSON(w, http.StatusOK, result)
	})

	mux.HandleFunc("/authenticate", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			writeJSON(w, http.StatusMethodNotAllowed, authenticateResponse{Success: false, Error: "Метод не поддерживается"})
			return
		}

		var req authenticateRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeJSON(w, http.StatusBadRequest, authenticateResponse{Success: false, Error: "Некорректное тело запроса"})
			return
		}

		result, err := authenticate(req.Config, req.Username, req.Password)
		if err != nil {
			writeJSON(w, http.StatusUnauthorized, authenticateResponse{Success: false, Error: err.Error()})
			return
		}
		writeJSON(w, http.StatusOK, result)
	})

	server := &http.Server{
		Addr:              ":" + env("PORT", "8086"),
		Handler:           mux,
		ReadHeaderTimeout: 10 * time.Second,
	}

	go func() {
		log.Printf("LDAP Auth запущен на %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Ошибка HTTP сервера: %v", err)
		}
	}()

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
	<-stop
	log.Println("Остановка LDAP Auth...")
}
