// Package ldapconfig manages the LDAP/AD configuration stored in PostgreSQL.
// Exactly one configuration record may exist (id=1). The bind password is
// encrypted with AES-256-GCM using a SHA-256 derived key from the application
// secret — identical to the scheme used in internal/imap/store.go.
package ldapconfig

import (
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"io"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

// GroupMapping maps an LDAP group DN to an archivmail role.
type GroupMapping struct {
	GroupDN string `json:"group_dn"`
	Role    string `json:"role"`
}

// LDAPConfig is the persisted LDAP/AD configuration.
type LDAPConfig struct {
	ID            int64          `json:"id"`
	Enabled       bool           `json:"enabled"`
	URL           string         `json:"url"`
	BindDN        string         `json:"bind_dn"`
	BindPassword  string         `json:"bind_password"` // masked as "••••••" in GET responses
	BaseDN        string         `json:"base_dn"`
	UserFilter    string         `json:"user_filter"`
	TLS           bool           `json:"tls"`
	TLSSkipVerify bool           `json:"tls_skip_verify"`
	DefaultRole   string         `json:"default_role"`
	GroupMappings []GroupMapping `json:"group_mappings"`
	UpdatedAt     time.Time      `json:"updated_at"`
	UpdatedBy     string         `json:"updated_by"`
}

const createTableSQL = `
CREATE TABLE IF NOT EXISTS ldap_config (
    id              BIGSERIAL PRIMARY KEY,
    enabled         BOOLEAN NOT NULL DEFAULT false,
    url             TEXT NOT NULL DEFAULT '',
    bind_dn         TEXT NOT NULL DEFAULT '',
    bind_password   BYTEA,
    base_dn         TEXT NOT NULL DEFAULT '',
    user_filter     TEXT NOT NULL DEFAULT '(sAMAccountName=%s)',
    tls             BOOLEAN NOT NULL DEFAULT false,
    tls_skip_verify BOOLEAN NOT NULL DEFAULT false,
    default_role    VARCHAR(20) NOT NULL DEFAULT 'user',
    group_mappings  JSONB NOT NULL DEFAULT '[]',
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_by      TEXT NOT NULL DEFAULT ''
);
`

// Store manages LDAP configuration persistence.
type Store struct {
	pool   *pgxpool.Pool
	encKey [32]byte
}

// New connects to PostgreSQL, creates the table if needed, and returns a Store.
// secret is the application secret used to derive the AES-256 encryption key.
func New(dsn, secret string) (*Store, error) {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		return nil, fmt.Errorf("ldapconfig: connect: %w", err)
	}

	key := sha256.Sum256([]byte(secret))
	s := &Store{pool: pool, encKey: key}

	if _, err := pool.Exec(ctx, createTableSQL); err != nil {
		pool.Close()
		return nil, fmt.Errorf("ldapconfig: init schema: %w", err)
	}

	return s, nil
}

// Close releases the underlying connection pool.
func (s *Store) Close() {
	s.pool.Close()
}

// Get returns the LDAP configuration with the bind password masked.
// Returns nil, nil when no configuration has been saved yet.
func (s *Store) Get(ctx context.Context) (*LDAPConfig, error) {
	cfg, err := s.query(ctx)
	if err != nil {
		return nil, err
	}
	if cfg == nil {
		return nil, nil
	}
	if cfg.BindPassword != "" {
		cfg.BindPassword = "••••••"
	}
	return cfg, nil
}

// GetWithPassword returns the LDAP configuration including the decrypted bind password.
// Returns nil, nil when no configuration has been saved yet.
func (s *Store) GetWithPassword(ctx context.Context) (*LDAPConfig, error) {
	return s.query(ctx)
}

// Save upserts the LDAP configuration (always uses id=1).
// When bindPassword is empty the existing stored password is preserved.
func (s *Store) Save(ctx context.Context, cfg LDAPConfig, updatedBy string) error {
	mappingsJSON, err := json.Marshal(cfg.GroupMappings)
	if err != nil {
		return fmt.Errorf("ldapconfig: marshal group_mappings: %w", err)
	}

	// Determine which password bytes to store.
	var encryptedPw []byte
	if cfg.BindPassword != "" {
		encryptedPw, err = s.encrypt(cfg.BindPassword)
		if err != nil {
			return fmt.Errorf("ldapconfig: encrypt password: %w", err)
		}
	} else {
		// Preserve existing password: read it from DB.
		existing, qErr := s.query(ctx)
		if qErr == nil && existing != nil && existing.BindPassword != "" {
			encryptedPw, err = s.encrypt(existing.BindPassword)
			if err != nil {
				return fmt.Errorf("ldapconfig: re-encrypt existing password: %w", err)
			}
		}
	}

	_, err = s.pool.Exec(ctx, `
		INSERT INTO ldap_config
			(id, enabled, url, bind_dn, bind_password, base_dn, user_filter, tls, tls_skip_verify,
			 default_role, group_mappings, updated_at, updated_by)
		VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), $11)
		ON CONFLICT (id) DO UPDATE SET
			enabled         = EXCLUDED.enabled,
			url             = EXCLUDED.url,
			bind_dn         = EXCLUDED.bind_dn,
			bind_password   = CASE WHEN EXCLUDED.bind_password IS NULL THEN ldap_config.bind_password ELSE EXCLUDED.bind_password END,
			base_dn         = EXCLUDED.base_dn,
			user_filter     = EXCLUDED.user_filter,
			tls             = EXCLUDED.tls,
			tls_skip_verify = EXCLUDED.tls_skip_verify,
			default_role    = EXCLUDED.default_role,
			group_mappings  = EXCLUDED.group_mappings,
			updated_at      = NOW(),
			updated_by      = EXCLUDED.updated_by
	`,
		cfg.Enabled, cfg.URL, cfg.BindDN, encryptedPw, cfg.BaseDN,
		cfg.UserFilter, cfg.TLS, cfg.TLSSkipVerify, cfg.DefaultRole,
		string(mappingsJSON), updatedBy,
	)
	if err != nil {
		return fmt.Errorf("ldapconfig: upsert: %w", err)
	}
	return nil
}

// Delete removes the LDAP configuration.
func (s *Store) Delete(ctx context.Context) error {
	_, err := s.pool.Exec(ctx, `DELETE FROM ldap_config WHERE id = 1`)
	return err
}

// query reads the single LDAP config row and decrypts the bind password.
func (s *Store) query(ctx context.Context) (*LDAPConfig, error) {
	row := s.pool.QueryRow(ctx, `
		SELECT id, enabled, url, bind_dn, bind_password, base_dn, user_filter,
		       tls, tls_skip_verify, default_role, group_mappings, updated_at, updated_by
		FROM ldap_config WHERE id = 1
	`)

	var cfg LDAPConfig
	var encPw []byte
	var mappingsRaw []byte

	err := row.Scan(
		&cfg.ID, &cfg.Enabled, &cfg.URL, &cfg.BindDN, &encPw,
		&cfg.BaseDN, &cfg.UserFilter, &cfg.TLS, &cfg.TLSSkipVerify,
		&cfg.DefaultRole, &mappingsRaw, &cfg.UpdatedAt, &cfg.UpdatedBy,
	)
	if err == pgx.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("ldapconfig: query: %w", err)
	}

	if len(encPw) > 0 {
		plain, err := s.decrypt(encPw)
		if err != nil {
			return nil, fmt.Errorf("ldapconfig: decrypt password: %w", err)
		}
		cfg.BindPassword = plain
	}

	if len(mappingsRaw) > 0 {
		if err := json.Unmarshal(mappingsRaw, &cfg.GroupMappings); err != nil {
			return nil, fmt.Errorf("ldapconfig: unmarshal group_mappings: %w", err)
		}
	}
	if cfg.GroupMappings == nil {
		cfg.GroupMappings = []GroupMapping{}
	}

	return &cfg, nil
}

// encrypt encrypts plaintext with AES-256-GCM using the store's key.
func (s *Store) encrypt(plaintext string) ([]byte, error) {
	block, err := aes.NewCipher(s.encKey[:])
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
		return nil, err
	}
	return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
}

// decrypt decrypts ciphertext produced by encrypt.
func (s *Store) decrypt(ciphertext []byte) (string, error) {
	block, err := aes.NewCipher(s.encKey[:])
	if err != nil {
		return "", err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}
	if len(ciphertext) < gcm.NonceSize() {
		return "", fmt.Errorf("ldapconfig: ciphertext too short")
	}
	nonce, data := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
	plain, err := gcm.Open(nil, nonce, data, nil)
	if err != nil {
		return "", err
	}
	return string(plain), nil
}
