package bdns

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/url"

	"github.com/miekg/dns"
)

// Error wraps a DNS error with various relevant information
type Error struct {
	recordType uint16
	hostname   string
	// Exactly one of rCode or underlying should be set.
	underlying error
	rCode      int

	// Optional: If the resolver returned extended error information, it will be stored here.
	// https://www.rfc-editor.org/rfc/rfc8914
	extended *dns.EDNS0_EDE
}

// extendedDNSError returns non-nil if the input message contained an OPT RR
// with an EDE option. https://www.rfc-editor.org/rfc/rfc8914.
func extendedDNSError(msg *dns.Msg) *dns.EDNS0_EDE {
	opt := msg.IsEdns0()
	if opt != nil {
		for _, opt := range opt.Option {
			ede, ok := opt.(*dns.EDNS0_EDE)
			if !ok {
				continue
			}
			return ede
		}
	}
	return nil
}

// wrapErr returns a non-nil error if err is non-nil or if resp.Rcode is not dns.RcodeSuccess.
// The error includes appropriate details about the DNS query that failed.
func wrapErr(queryType uint16, hostname string, resp *dns.Msg, err error) error {
	if err != nil {
		return Error{
			recordType: queryType,
			hostname:   hostname,
			underlying: err,
			extended:   nil,
		}
	}
	if resp.Rcode != dns.RcodeSuccess {
		return Error{
			recordType: queryType,
			hostname:   hostname,
			rCode:      resp.Rcode,
			underlying: nil,
			extended:   extendedDNSError(resp),
		}
	}
	return nil
}

// A copy of miekg/dns's mapping of error codes to strings. We tweak it slightly so all DNSSEC-related
// errors say "DNSSEC" at the beginning.
// https://pkg.go.dev/github.com/miekg/dns#ExtendedErrorCodeToString
// Also note that not all of these codes can currently be emitted by Unbound. See Unbound's
// announcement post for EDE: https://blog.nlnetlabs.nl/extended-dns-error-support-for-unbound/
var extendedErrorCodeToString = map[uint16]string{
	dns.ExtendedErrorCodeOther:                      "Other",
	dns.ExtendedErrorCodeUnsupportedDNSKEYAlgorithm: "DNSSEC: Unsupported DNSKEY Algorithm",
	dns.ExtendedErrorCodeUnsupportedDSDigestType:    "DNSSEC: Unsupported DS Digest Type",
	dns.ExtendedErrorCodeStaleAnswer:                "Stale Answer",
	dns.ExtendedErrorCodeForgedAnswer:               "Forged Answer",
	dns.ExtendedErrorCodeDNSSECIndeterminate:        "DNSSEC: Indeterminate",
	dns.ExtendedErrorCodeDNSBogus:                   "DNSSEC: Bogus",
	dns.ExtendedErrorCodeSignatureExpired:           "DNSSEC: Signature Expired",
	dns.ExtendedErrorCodeSignatureNotYetValid:       "DNSSEC: Signature Not Yet Valid",
	dns.ExtendedErrorCodeDNSKEYMissing:              "DNSSEC: DNSKEY Missing",
	dns.ExtendedErrorCodeRRSIGsMissing:              "DNSSEC: RRSIGs Missing",
	dns.ExtendedErrorCodeNoZoneKeyBitSet:            "DNSSEC: No Zone Key Bit Set",
	dns.ExtendedErrorCodeNSECMissing:                "DNSSEC: NSEC Missing",
	dns.ExtendedErrorCodeCachedError:                "Cached Error",
	dns.ExtendedErrorCodeNotReady:                   "Not Ready",
	dns.ExtendedErrorCodeBlocked:                    "Blocked",
	dns.ExtendedErrorCodeCensored:                   "Censored",
	dns.ExtendedErrorCodeFiltered:                   "Filtered",
	dns.ExtendedErrorCodeProhibited:                 "Prohibited",
	dns.ExtendedErrorCodeStaleNXDOMAINAnswer:        "Stale NXDOMAIN Answer",
	dns.ExtendedErrorCodeNotAuthoritative:           "Not Authoritative",
	dns.ExtendedErrorCodeNotSupported:               "Not Supported",
	dns.ExtendedErrorCodeNoReachableAuthority:       "No Reachable Authority",
	dns.ExtendedErrorCodeNetworkError:               "Network Error between Resolver and Authority",
	dns.ExtendedErrorCodeInvalidData:                "Invalid Data",
}

func (d Error) Error() string {
	var detail, additional string
	if d.underlying != nil {
		var netErr *net.OpError
		var urlErr *url.Error
		if errors.As(d.underlying, &netErr) {
			if netErr.Timeout() {
				detail = detailDNSTimeout
			} else {
				detail = detailDNSNetFailure
			}
			// Note: we check d.underlying here even though `Timeout()` does this because the call to `netErr.Timeout()` above only
			// happens for `*net.OpError` underlying types!
		} else if errors.As(d.underlying, &urlErr) && urlErr.Timeout() {
			// For DOH queries, we can get back a `*url.Error` that wraps the unexported type
			// `http.httpError`. Unfortunately `http.httpError` doesn't wrap any errors (like
			// context.DeadlineExceeded), we can't check for that; instead we need to call Timeout().
			detail = detailDNSTimeout
		} else if errors.Is(d.underlying, context.DeadlineExceeded) {
			detail = detailDNSTimeout
		} else if errors.Is(d.underlying, context.Canceled) {
			detail = detailCanceled
		} else {
			detail = detailServerFailure
		}
	} else if d.rCode != dns.RcodeSuccess {
		detail = dns.RcodeToString[d.rCode]
		if explanation, ok := rcodeExplanations[d.rCode]; ok {
			additional = " - " + explanation
		}
	} else {
		detail = detailServerFailure
	}

	if d.extended == nil {
		return fmt.Sprintf("DNS problem: %s looking up %s for %s%s", detail,
			dns.TypeToString[d.recordType], d.hostname, additional)
	}

	summary := extendedErrorCodeToString[d.extended.InfoCode]
	if summary == "" {
		summary = fmt.Sprintf("Unknown Extended DNS Error code %d", d.extended.InfoCode)
	}
	result := fmt.Sprintf("DNS problem: looking up %s for %s: %s",
		dns.TypeToString[d.recordType], d.hostname, summary)
	if d.extended.ExtraText != "" {
		result = result + ": " + d.extended.ExtraText
	}
	return result
}

const detailDNSTimeout = "query timed out"
const detailCanceled = "query timed out (and was canceled)"
const detailDNSNetFailure = "networking error"
const detailServerFailure = "server failure at resolver"

// rcodeExplanations provide additional friendly explanatory text to be included in DNS
// error messages, for select inscrutable RCODEs.
var rcodeExplanations = map[int]string{
	dns.RcodeNameError:     "check that a DNS record exists for this domain",
	dns.RcodeServerFailure: "the domain's nameservers may be malfunctioning",
}
