package http01

import (
	"fmt"
	"net/http"
	"net/netip"
	"strings"
)

// A domainMatcher tries to match a domain (the one we're requesting a certificate for)
// in the HTTP request coming from the ACME validation servers.
// This step is part of DNS rebind attack prevention,
// where the webserver matches incoming requests to a list of domain the server acts authoritative for.
//
// The most simple check involves finding the domain in the HTTP Host header;
// this is what hostMatcher does.
// Use it, when the http01.ProviderServer is directly reachable from the internet,
// or when it operates behind a transparent proxy.
//
// In many (reverse) proxy setups, Apache and NGINX traditionally move the Host header to a new header named X-Forwarded-Host.
// Use arbitraryMatcher("X-Forwarded-Host") in this case,
// or the appropriate header name for other proxy servers.
//
// RFC7239 has standardized the different forwarding headers into a single header named Forwarded.
// The header value has a different format, so you should use forwardedMatcher
// when the http01.ProviderServer operates behind a RFC7239 compatible proxy.
// https://www.rfc-editor.org/rfc/rfc7239.html
//
// Note: RFC7239 also reminds us, "that an HTTP list [...] may be split over multiple header fields" (section 7.1),
// meaning that
//
//	X-Header: a
//	X-Header: b
//
// is equal to
//
//	X-Header: a, b
//
// All matcher implementations (explicitly not excluding arbitraryMatcher!)
// have in common that they only match against the first value in such lists.
type domainMatcher interface {
	// matches checks whether the request is valid for the given domain.
	matches(request *http.Request, domain string) bool

	// name returns the header name used in the check.
	// This is primarily used to create meaningful error messages.
	name() string
}

// hostMatcher checks whether (*net/http).Request.Host starts with a domain name.
type hostMatcher struct{}

func (m *hostMatcher) name() string {
	return "Host"
}

func (m *hostMatcher) matches(r *http.Request, domain string) bool {
	return matchDomain(r.Host, domain)
}

// arbitraryMatcher checks whether the specified (*net/http.Request).Header value starts with a domain name.
type arbitraryMatcher string

func (m arbitraryMatcher) name() string {
	return string(m)
}

func (m arbitraryMatcher) matches(r *http.Request, domain string) bool {
	return matchDomain(r.Header.Get(m.name()), domain)
}

// forwardedMatcher checks whether the Forwarded header contains a "host" element starting with a domain name.
// See https://www.rfc-editor.org/rfc/rfc7239.html for details.
type forwardedMatcher struct{}

func (m *forwardedMatcher) name() string {
	return "Forwarded"
}

func (m *forwardedMatcher) matches(r *http.Request, domain string) bool {
	fwds, err := parseForwardedHeader(r.Header.Get(m.name()))
	if err != nil {
		return false
	}

	if len(fwds) == 0 {
		return false
	}

	host := fwds[0]["host"]

	return matchDomain(host, domain)
}

// parsing requires some form of state machine.
func parseForwardedHeader(s string) (elements []map[string]string, err error) {
	cur := make(map[string]string)
	key := ""
	val := ""
	inquote := false

	pos := 0

	l := len(s)
	for i := 0; i < l; i++ {
		r := rune(s[i])

		if inquote {
			if r == '"' {
				cur[key] = s[pos:i]
				key = ""
				pos = i
				inquote = false
			}

			continue
		}

		switch {
		case r == '"': // start of quoted-string
			if key == "" {
				return nil, fmt.Errorf("unexpected quoted string as pos %d", i)
			}

			inquote = true
			pos = i + 1

		case r == ';': // end of forwarded-pair
			cur[key] = s[pos:i]
			key = ""
			i = skipWS(s, i)
			pos = i + 1

		case r == '=': // end of token
			key = strings.ToLower(strings.TrimFunc(s[pos:i], isWS))
			i = skipWS(s, i)
			pos = i + 1

		case r == ',': // end of forwarded-element
			if key != "" {
				val = s[pos:i]
				cur[key] = val
			}

			elements = append(elements, cur)
			cur = make(map[string]string)
			key = ""
			val = ""

			i = skipWS(s, i)
			pos = i + 1
		case tchar(r) || isWS(r): // valid token character or whitespace
			continue
		default:
			return nil, fmt.Errorf("invalid token character at pos %d: %c", i, r)
		}
	}

	if inquote {
		return nil, fmt.Errorf("unterminated quoted-string at pos %d", len(s))
	}

	if key != "" {
		if pos < len(s) {
			val = s[pos:]
		}

		cur[key] = val
	}

	if len(cur) > 0 {
		elements = append(elements, cur)
	}

	return elements, nil
}

func tchar(r rune) bool {
	return strings.ContainsRune("!#$%&'*+-.^_`|~", r) ||
		'0' <= r && r <= '9' ||
		'a' <= r && r <= 'z' ||
		'A' <= r && r <= 'Z'
}

func skipWS(s string, i int) int {
	for isWS(rune(s[i+1])) {
		i++
	}

	return i
}

func isWS(r rune) bool {
	return strings.ContainsRune(" \t\v\r\n", r)
}

func matchDomain(src, domain string) bool {
	addr, err := netip.ParseAddr(domain)
	if err == nil && addr.Is6() {
		domain = "[" + domain + "]"
	}

	return strings.HasPrefix(src, domain)
}
