// Package adsync queries Active Directory for computer objects and stores
// them in the local database. It enriches existing hosts with their AD
// computer name and department, and records computers that are in AD but
// not yet seen on the network (useful for finding offline/missing devices).
package adsync

import (
	"context"
	"fmt"
	"log/slog"
	"strconv"
	"strings"
	"time"

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

	"github.com/cereda-systems/nexus-scanner/internal/config"
	"github.com/cereda-systems/nexus-scanner/internal/db"
)

// Module queries Active Directory for all computer objects.
type Module struct {
	site  string
	cfg   config.ADConfig
	store *db.Store
	log   *slog.Logger
}

// New returns a new AD sync module.
func New(site string, cfg config.ADConfig, store *db.Store) *Module {
	return &Module{
		site:  site,
		cfg:   cfg,
		store: store,
		log:   slog.With("module", "ad_sync"),
	}
}

func (m *Module) Name() string            { return "ad_sync" }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }

// Run executes one AD sync cycle.
func (m *Module) Run(ctx context.Context) error {
	scanID, err := m.store.BeginScan(m.Name())
	if err != nil {
		return fmt.Errorf("begin scan: %w", err)
	}
	scanErr := m.sync(ctx)
	if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
		m.log.Error("record scan end", "err", endErr)
	}
	return scanErr
}

func (m *Module) sync(ctx context.Context) error {
	addr := fmt.Sprintf("%s:%d", m.cfg.Server, m.cfg.Port)

	var conn *ldap.Conn
	var err error
	if m.cfg.TLS {
		conn, err = ldap.DialURL("ldaps://" + addr)
	} else {
		conn, err = ldap.DialURL("ldap://" + addr)
	}
	if err != nil {
		return fmt.Errorf("ldap dial %s: %w", addr, err)
	}
	defer conn.Close()

	if err := conn.Bind(m.cfg.BindDN, m.cfg.BindPassword); err != nil {
		return fmt.Errorf("ldap bind as %s: %w", m.cfg.BindDN, err)
	}

	computers, err := m.searchComputers(conn)
	if err != nil {
		return fmt.Errorf("ldap search: %w", err)
	}

	m.log.Info("computers found in AD", "count", len(computers))

	saved := 0
	for _, c := range computers {
		if err := m.store.UpsertADComputer(c); err != nil {
			m.log.Error("upsert ad computer", "cn", c.CN, "err", err)
			continue
		}
		saved++
		m.log.Debug("ad computer synced", "cn", c.CN, "os", c.OS, "dept", c.Department)
	}

	m.log.Info("ad sync complete", "synced", saved, "total", len(computers))
	return nil
}

func (m *Module) searchComputers(conn *ldap.Conn) ([]db.ADComputer, error) {
	req := ldap.NewSearchRequest(
		m.cfg.SearchBase,
		ldap.ScopeWholeSubtree,
		ldap.NeverDerefAliases,
		0, 0, false,
		"(&(objectClass=computer)(objectCategory=Computer))",
		[]string{
			"cn",
			"distinguishedName",
			"operatingSystem",
			"operatingSystemVersion",
			"lastLogonTimestamp",
			"whenCreated",
			"description",
			"managedBy",
		},
		nil,
	)

	result, err := conn.SearchWithPaging(req, 500)
	if err != nil {
		return nil, fmt.Errorf("ldap search computers: %w", err)
	}

	var computers []db.ADComputer
	for _, entry := range result.Entries {
		c := db.ADComputer{
			CN:        entry.GetAttributeValue("cn"),
			DN:        entry.GetAttributeValue("distinguishedName"),
			OS:        entry.GetAttributeValue("operatingSystem"),
			OSVersion: entry.GetAttributeValue("operatingSystemVersion"),
			Owner:     entry.GetAttributeValue("description"),
			Department: ouFromDN(entry.GetAttributeValue("distinguishedName")),
		}

		if raw := entry.GetAttributeValue("lastLogonTimestamp"); raw != "" {
			c.LastLogon = windowsTimeToGoTime(raw)
		}
		if raw := entry.GetAttributeValue("whenCreated"); raw != "" {
			c.ADCreated = parseLDAPGeneralizedTime(raw)
		}

		computers = append(computers, c)
	}

	return computers, nil
}

// ouFromDN extracts the first OU component from a distinguished name.
// "CN=IT-NB-02,OU=Notebooks,OU=IT,DC=winkel,DC=local" → "Notebooks"
func ouFromDN(dn string) string {
	for _, part := range strings.Split(dn, ",") {
		part = strings.TrimSpace(part)
		if strings.HasPrefix(strings.ToUpper(part), "OU=") {
			return part[3:]
		}
	}
	return ""
}

// windowsTimeToGoTime converts a Windows LDAP timestamp string (100-ns intervals
// since 1601-01-01) to a Go time.Time.
func windowsTimeToGoTime(raw string) time.Time {
	n, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || n == 0 {
		return time.Time{}
	}
	// Offset between Windows epoch (1601-01-01) and Unix epoch (1970-01-01)
	// in 100-nanosecond intervals.
	const windowsToUnix int64 = 116444736000000000
	unixNano := (n - windowsToUnix) * 100
	return time.Unix(0, unixNano).UTC()
}

// parseLDAPGeneralizedTime parses LDAP generalizedTime format: "20240101120000.0Z"
func parseLDAPGeneralizedTime(raw string) time.Time {
	// Trim trailing sub-second and timezone info for simple parsing.
	raw = strings.TrimSuffix(raw, "Z")
	if idx := strings.Index(raw, "."); idx != -1 {
		raw = raw[:idx]
	}
	t, err := time.Parse("20060102150405", raw)
	if err != nil {
		return time.Time{}
	}
	return t.UTC()
}
