package security

import (
	"context"
	"net"
	"net/netip"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/tech/sendico/pkg/merrors"
	"github.com/tech/sendico/pkg/mlogger"
	"go.uber.org/zap"
)

type service struct {
	logger       mlogger.Logger
	requireHTTPS bool
	allowedHosts map[string]struct{}
	allowedPorts map[int]struct{}
	dnsTimeout   time.Duration
	resolver     *net.Resolver
}

// New creates URL validator.
func New(logger mlogger.Logger, cfg Config) Validator {
	if logger == nil {
		logger = zap.NewNop()
	}

	hosts := make(map[string]struct{}, len(cfg.AllowedHosts))
	for _, host := range cfg.AllowedHosts {
		h := strings.ToLower(strings.TrimSpace(host))
		if h == "" {
			continue
		}
		hosts[h] = struct{}{}
	}
	ports := make(map[int]struct{}, len(cfg.AllowedPorts))
	for _, port := range cfg.AllowedPorts {
		if port > 0 {
			ports[port] = struct{}{}
		}
	}

	timeout := time.Duration(cfg.DNSResolveTimeout) * time.Millisecond
	if timeout <= 0 {
		timeout = 2 * time.Second
	}

	return &service{
		logger:       logger.Named("security"),
		requireHTTPS: cfg.RequireHTTPS,
		allowedHosts: hosts,
		allowedPorts: ports,
		dnsTimeout:   timeout,
		resolver:     net.DefaultResolver,
	}
}

func (s *service) ValidateURL(ctx context.Context, target string) error {
	parsed, err := url.Parse(strings.TrimSpace(target))
	if err != nil {
		s.logger.Warn("Failed to parse callback URL", zap.Error(err))
		return merrors.InvalidArgumentWrap(err, "invalid callback URL", "url")
	}
	if parsed == nil || parsed.Host == "" {
		s.logger.Warn("Callback URL host is required")
		return merrors.InvalidArgument("callback URL host is required", "url")
	}
	if parsed.User != nil {
		s.logger.Warn("Rejected callback URL with credentials", zap.String("host", parsed.Hostname()))
		return merrors.InvalidArgument("callback URL credentials are not allowed", "url")
	}
	if s.requireHTTPS && !strings.EqualFold(parsed.Scheme, "https") {
		s.logger.Warn("Rejected callback URL due to non-https scheme",
			zap.String("scheme", parsed.Scheme),
			zap.String("host", parsed.Hostname()),
		)
		return merrors.InvalidArgument("callback URL must use HTTPS", "url")
	}

	host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
	if host == "" {
		s.logger.Warn("Callback URL host is empty")
		return merrors.InvalidArgument("callback URL host is empty", "url")
	}
	if len(s.allowedHosts) > 0 {
		if _, ok := s.allowedHosts[host]; !ok {
			s.logger.Warn("Rejected callback host not present in allowlist", zap.String("host", host))
			return merrors.InvalidArgument("callback host is not in allowlist", "url.host")
		}
	}

	port, err := resolvePort(parsed)
	if err != nil {
		return err
	}
	if len(s.allowedPorts) > 0 {
		if _, ok := s.allowedPorts[port]; !ok {
			s.logger.Warn("Rejected callback URL port not present in allowlist",
				zap.String("host", host),
				zap.Int("port", port),
			)
			return merrors.InvalidArgument("callback URL port is not allowed", "url.port")
		}
	}

	if addr, addrErr := netip.ParseAddr(host); addrErr == nil {
		if isBlocked(addr) {
			s.logger.Warn("Rejected callback URL with blocked IP address",
				zap.String("host", host),
				zap.String("ip", addr.String()),
			)
			return merrors.InvalidArgument("callback URL resolves to blocked IP range", "url")
		}
		return nil
	}

	lookupCtx := ctx
	if lookupCtx == nil {
		lookupCtx = context.Background()
	}
	lookupCtx, cancel := context.WithTimeout(lookupCtx, s.dnsTimeout)
	defer cancel()

	ips, err := s.resolver.LookupIPAddr(lookupCtx, host)
	if err != nil {
		s.logger.Warn("Failed to resolve callback host", zap.String("host", host), zap.Error(err))
		return merrors.InternalWrap(err, "failed to resolve callback host")
	}
	if len(ips) == 0 {
		s.logger.Warn("Callback host did not resolve", zap.String("host", host))
		return merrors.InvalidArgument("callback host did not resolve", "url.host")
	}
	for _, ip := range ips {
		if ip.IP == nil {
			continue
		}
		addr, ok := netip.AddrFromSlice(ip.IP)
		if ok && isBlocked(addr) {
			s.logger.Warn("Rejected callback URL resolving to blocked IP address",
				zap.String("host", host),
				zap.String("ip", addr.String()),
			)
			return merrors.InvalidArgument("callback URL resolves to blocked IP range", "url.host")
		}
	}

	return nil
}

func resolvePort(parsed *url.URL) (int, error) {
	if parsed == nil {
		return 0, merrors.InvalidArgument("callback URL is required", "url")
	}
	portStr := strings.TrimSpace(parsed.Port())
	if portStr == "" {
		if strings.EqualFold(parsed.Scheme, "https") {
			return 443, nil
		}
		if strings.EqualFold(parsed.Scheme, "http") {
			return 80, nil
		}
		return 0, merrors.InvalidArgument("callback URL scheme is not supported", "url.scheme")
	}

	port, err := strconv.Atoi(portStr)
	if err != nil || port <= 0 || port > 65535 {
		return 0, merrors.InvalidArgument("callback URL port is invalid", "url.port")
	}

	return port, nil
}

func isBlocked(ip netip.Addr) bool {
	if !ip.IsValid() {
		return true
	}
	if ip.IsLoopback() || ip.IsPrivate() || ip.IsMulticast() || ip.IsUnspecified() {
		return true
	}
	if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
		return true
	}

	// Block common cloud metadata endpoint.
	if ip.Is4() && ip.String() == "169.254.169.254" {
		return true
	}

	return false
}
