package adauth

import (
	"context"
	"crypto/x509"
	"encoding/hex"
	"encoding/pem"
	"fmt"
	"io"
	"net"
	"os"
	"strconv"
	"strings"

	"github.com/RedTeamPentesting/adauth/x509ext"
	"github.com/spf13/pflag"
)

// Options holds command line options that are used to determine authentication
// credentials and target.
type Options struct {
	// Username (with domain) in one of the following formats:
	// `UPN`, `domain\user`, `domain/user` or `user`
	User             string
	Password         string
	NTHash           string
	AESKey           string
	CCache           string
	DomainController string
	ForceKerberos    bool

	// It is possible to specify a cert/key pair directly, as PEM files or as a
	// single PFX file.
	Certificate     *x509.Certificate
	CertificateKey  any
	PFXFileName     string
	PFXPassword     string
	PEMCertFileName string
	PEMKeyFileName  string

	credential *Credential
	flagset    *pflag.FlagSet

	Debug    func(fmt string, a ...any)
	Resolver Resolver
}

// RegisterFlags registers authentication flags to a pflag.FlagSet such as the
// default flagset `pflag.CommandLine`.
func (opts *Options) RegisterFlags(flagset *pflag.FlagSet) {
	defaultCCACHEFile := os.Getenv("KRB5CCNAME")
	ccacheHint := ""

	if defaultCCACHEFile == "" {
		ccacheHint = " (defaults to $KRB5CCNAME, currently unset)"
	}

	flagset.StringVarP(&opts.User, "user", "u", "",
		"Username ('`user@domain`', 'domain\\user', 'domain/user' or 'user')")
	flagset.StringVarP(&opts.Password, "password", "p", "", "Password")
	flagset.StringVarP(&opts.NTHash, "nt-hash", "H", "", "NT `hash` ('NT', ':NT' or 'LM:NT')")
	flagset.StringVar(&opts.AESKey, "aes-key", "", "Kerberos AES `hex key`")
	flagset.StringVar(&opts.PFXFileName, "pfx", "", "Client certificate and private key as PFX `file`")
	flagset.StringVar(&opts.PFXPassword, "pfx-password", "", "Password for PFX file")
	flagset.StringVar(&opts.CCache, "ccache", defaultCCACHEFile, "Kerberos CCache `file` name"+ccacheHint)
	flagset.StringVar(&opts.DomainController, "dc", "", "Domain controller")
	flagset.BoolVarP(&opts.ForceKerberos, "kerberos", "k", false, "Use Kerberos authentication")
	opts.flagset = flagset
}

func (opts *Options) debug(format string, a ...any) {
	if opts.Debug != nil {
		opts.Debug(format, a...)
	}
}

func portForProtocol(protocol string) string {
	switch strings.ToLower(protocol) {
	case "ldap":
		return "389"
	case "ldaps":
		return "636"
	case "http":
		return "80"
	case "https":
		return "443"
	case "smb":
		return "445"
	case "rdp":
		return "3389"
	case "kerberos":
		return "88"
	default:
		return ""
	}
}

func addPortForProtocolIfMissing(protocol string, addr string) string {
	host, port, err := net.SplitHostPort(addr)
	if err != nil || port != "" {
		return addr
	}

	port = portForProtocol(protocol)
	if port == "" {
		return addr
	}

	return net.JoinHostPort(host, port)
}

// WithDCTarget returns credentials and the domain controller for the
// corresponding domain as the target.
func (opts *Options) WithDCTarget(ctx context.Context, protocol string) (*Credential, *Target, error) {
	if opts.DomainController != "" {
		return opts.WithTarget(ctx, protocol, addPortForProtocolIfMissing(protocol, opts.DomainController))
	}

	cred, err := opts.preliminaryCredential()
	if err != nil {
		return nil, nil, err
	}

	if cred.Domain == "" {
		return nil, nil, fmt.Errorf("domain unknown")
	}

	resolver := ensureResolver(opts.Resolver, opts.debug)

	var dcAddr string

	host, port, err := resolver.LookupFirstService(ctx, protocol, cred.Domain)
	if err != nil {
		lookupSRVErr := fmt.Errorf("could not lookup %q service of domain %q: %w", protocol, cred.Domain, err)

		dcAddr, err = resolver.LookupDCByDomain(ctx, cred.Domain)
		if err != nil {
			return nil, nil, fmt.Errorf("could not find DC: %w and %w", lookupSRVErr, err)
		}

		port := portForProtocol(protocol)
		if port != "" {
			dcAddr = net.JoinHostPort(dcAddr, port)
		}

		opts.debug("using DC %s based on domain lookup for %s", dcAddr, cred.Domain)
	} else {
		dcAddr = net.JoinHostPort(host, strconv.Itoa(port))
		opts.debug("using DC %s based on SRV lookup for domain %s", dcAddr, cred.Domain)
	}

	return cred, newTarget(
		protocol, dcAddr, opts.ForceKerberos || cred.mustUseKerberos(), opts.CCache, opts.Resolver), nil
}

// WithTarget returns credentials and the specified target.
func (opts *Options) WithTarget(ctx context.Context, protocol string, target string) (*Credential, *Target, error) {
	if protocol == "" {
		protocol = "host"
	}

	cred, err := opts.preliminaryCredential()
	if err != nil {
		return nil, nil, err
	}

	t := newTarget(protocol, target, opts.ForceKerberos || cred.mustUseKerberos(), opts.CCache, opts.Resolver)

	if cred.Domain == "" {
		hostname, err := t.Hostname(ctx)
		if err != nil {
			return nil, nil, fmt.Errorf("lookup target hostname to determine domain: %w", err)
		}

		parts := strings.SplitN(hostname, ".", 2)
		if len(parts) == 2 {
			switch {
			case strings.Contains(parts[1], "."):
				cred.Domain = parts[1]
			default:
				cred.Domain = hostname
			}
		}
	}

	return cred, t, nil
}

// Username returns the user's name. Username may return an empty string.
func (opts *Options) Username() string {
	cred, err := opts.preliminaryCredential()
	if err != nil {
		return ""
	}

	return cred.Username
}

// UPN returns the user's domain. Domain may return an empty string.
func (opts *Options) Domain() string {
	cred, err := opts.preliminaryCredential()
	if err != nil {
		return ""
	}

	return cred.Domain
}

// UPN returns the user's universal principal name. UPN may return an empty
// string.
func (opts *Options) UPN() string {
	cred, err := opts.preliminaryCredential()
	if err != nil {
		return ""
	}

	return cred.UPN()
}

// NoTarget returns the user credentials without supplementing it with
// information from a target.
func (opts *Options) NoTarget() (*Credential, error) {
	return opts.preliminaryCredential()
}

func (opts *Options) preliminaryCredential() (*Credential, error) {
	if opts.credential != nil {
		return opts.credential, nil
	}

	domain, username := splitUserIntoDomainAndUsername(opts.User)

	cleanedNTHash := cleanNTHash(opts.NTHash)

	var ntHash string

	if cleanedNTHash != "" {
		ntHashBytes, err := hex.DecodeString(cleanedNTHash)
		if err != nil {
			return nil, fmt.Errorf("invalid NT hash: parse hex: %w", err)
		} else if len(ntHashBytes) != 16 {
			return nil, fmt.Errorf("invalid NT hash: %d bytes instead of 16", len(ntHashBytes))
		}

		ntHash = cleanedNTHash
	}

	var aesKey string

	if opts.AESKey != "" {
		aesKeyBytes, err := hex.DecodeString(opts.AESKey)
		if err != nil {
			return nil, fmt.Errorf("invalid AES key: parse hex: %w", err)
		} else if len(aesKeyBytes) != 16 && len(aesKeyBytes) != 32 {
			return nil, fmt.Errorf("invalid AES key: %d bytes instead of 16 or 32", len(aesKeyBytes))
		}

		aesKey = opts.AESKey
	}

	var ccache string

	if opts.CCache != "" {
		s, err := os.Stat(opts.CCache)
		if err != nil {
			return nil, fmt.Errorf("stat CCache path: %w", err)
		} else if s.IsDir() {
			return nil, fmt.Errorf("CCache path is a directory: %s", opts.CCache)
		}

		ccache = opts.CCache
	}

	cred := &Credential{
		Username:              username,
		Password:              opts.Password,
		Domain:                domain,
		NTHash:                cleanNTHash(ntHash),
		AESKey:                aesKey,
		CCache:                ccache,
		dc:                    opts.DomainController,
		PasswordIsEmptyString: opts.Password == "" && (opts.flagset != nil && opts.flagset.Changed("password")),
		CCacheIsFromEnv:       opts.CCache != "" && (opts.flagset != nil && !opts.flagset.Changed("ccache")),
		Resolver:              opts.Resolver,
	}

	switch {
	case opts.Certificate != nil && opts.CertificateKey == nil:
		return nil, fmt.Errorf("specify a key file for the client certificate")
	case opts.Certificate != nil && opts.CertificateKey != nil:
		cred.ClientCert = opts.Certificate
		cred.ClientCertKey = opts.CertificateKey
	case opts.PFXFileName != "":
		cert, key, caCerts, err := readPFX(opts.PFXFileName, opts.PFXPassword)
		if err != nil {
			return nil, err
		}

		cred.ClientCert = cert
		cred.ClientCertKey = key
		cred.CACerts = caCerts
	case opts.PEMCertFileName != "" && opts.PEMKeyFileName == "":
		return nil, fmt.Errorf("specify a key file for the client certificate")
	case opts.PEMCertFileName != "" && opts.PEMKeyFileName != "":
		cert, key, err := readPEMCertAndKey(opts.PEMCertFileName, opts.PEMKeyFileName)
		if err != nil {
			return nil, err
		}

		cred.ClientCert = cert
		cred.ClientCertKey = key
	}

	//nolint:nestif
	if cred.ClientCert != nil {
		user, domain, err := x509ext.UserAndDomainFromOtherNames(cred.ClientCert)
		if err == nil {
			if cred.Username == "" {
				cred.Username = user
			}

			if cred.Domain == "" {
				cred.Domain = domain
			}
		}
	}

	opts.credential = cred

	return cred, nil
}

func readPFX(fileName string, password string) (*x509.Certificate, any, []*x509.Certificate, error) {
	pfxData, err := os.ReadFile(fileName)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("read PFX: %w", err)
	}

	key, cert, caCerts, err := DecodePFX(pfxData, password)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("decode PFX: %w", err)
	}

	return cert, key, caCerts, nil
}

func readPEMCertAndKey(certFileName string, certKeyFileName string) (*x509.Certificate, any, error) {
	certData, err := os.ReadFile(certFileName)
	if err != nil {
		return nil, nil, fmt.Errorf("read cert file: %w", err)
	}

	block, _ := pem.Decode(certData)
	if block == nil {
		return nil, nil, fmt.Errorf("could not PEM-decode certificate")
	}

	if block.Type != "" && !strings.Contains(strings.ToLower(block.Type), "certificate") {
		return nil, nil, fmt.Errorf("unexpected block type for certificate: %q", block.Type)
	}

	cert, err := x509.ParseCertificate(block.Bytes)
	if err != nil {
		return nil, nil, fmt.Errorf("parse certificate: %w", err)
	}

	certKeyData, err := os.ReadFile(certKeyFileName)
	if err != nil {
		return nil, nil, fmt.Errorf("read cert key file: %w", err)
	}

	block, _ = pem.Decode(certKeyData)
	if block == nil {
		return nil, nil, fmt.Errorf("could not PEM-decode certificate key")
	}

	if block.Type != "" && !strings.Contains(strings.ToLower(block.Type), "key") {
		return nil, nil, fmt.Errorf("unexpected block type for key: %q", block.Type)
	}

	key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err != nil {
		key, pkcs1Err := x509.ParsePKCS1PrivateKey(block.Bytes)
		if pkcs1Err == nil {
			return cert, key, nil
		}

		return nil, nil, fmt.Errorf("parse private key: %w", err)
	}

	return cert, key, nil
}

// NewDebugFunc creates a debug output handler.
func NewDebugFunc(enabled *bool, writer io.Writer, colored bool) func(string, ...any) {
	return func(format string, a ...any) {
		if enabled == nil || !*enabled {
			return
		}

		format = strings.TrimRight(format, "\n")
		if colored {
			format = "\033[2m" + format + "\033[0m"
		}

		_, _ = fmt.Fprintf(writer, format+"\n", a...)
	}
}

func cleanNTHash(h string) string {
	if !strings.Contains(h, ":") {
		return h
	}

	parts := strings.Split(h, ":")
	if len(parts) != 2 {
		return h
	}

	return parts[1]
}
