package checker

import (
	"context"
	"errors"
	"io"
	"net"
	"syscall"
)

// Classification is the result of classifyError. Transient errors are
// candidates for one auto-retry (governed by Config.MaxRetries in
// checker.go). Permanent errors are reported to the user as-is.
type Classification int

const (
	// ClassificationPermanent — caller should NOT retry. Either the user
	// config is wrong (bad credentials, refused), the proxy is broken in
	// a way retry won't fix (bad SOCKS5 version, malformed STUN reply),
	// or the caller's context is already cancelled.
	ClassificationPermanent Classification = iota
	// ClassificationTransient — caller MAY retry once. Network blip,
	// timeout, RST mid-handshake, DNS temporary failure.
	ClassificationTransient
)

// classifyError decides whether err is worth retrying.
//
// Transient (retry):
//   - net.Error.Timeout() == true
//   - errors.Is(err, syscall.ECONNRESET)
//   - net.DNSError.IsTemporary || .IsTimeout
//   - io.ErrUnexpectedEOF wrapped inside a *net.OpError on a Read (proxy
//     hung up mid-reply mid-flight; bare io.ErrUnexpectedEOF without an
//     OpError wrapper means we got a malformed reply and should not retry)
//
// Permanent (don't retry):
//   - context.Canceled / context.DeadlineExceeded
//   - errors.Is(err, syscall.ECONNREFUSED)
//   - any of our SOCKS5/STUN sentinels
//   - everything else we don't explicitly classify
//
// Returns ClassificationPermanent on nil err (defensive).
func classifyError(err error) Classification {
	if err == nil {
		return ClassificationPermanent
	}

	// Context cancellation always wins — don't retry into a cancelled
	// context, even if the chain also contains a timeout error.
	if isContextErr(err) {
		return ClassificationPermanent
	}

	// Permanent: explicit refused.
	if errors.Is(err, syscall.ECONNREFUSED) {
		return ClassificationPermanent
	}

	// Permanent: our SOCKS5 sentinels (auth refused, bad version,
	// malformed credentials, etc.). Retrying won't fix any of these.
	switch {
	case errors.Is(err, ErrSocks5BadVersion),
		errors.Is(err, ErrSocks5RejectedAllAuth),
		errors.Is(err, ErrAuthRejected),
		errors.Is(err, ErrCredentialTooLong),
		errors.Is(err, ErrHostTooLong),
		errors.Is(err, ErrUnsupportedRelayATYP),
		errors.Is(err, ErrShortReply):
		return ClassificationPermanent
	}

	// Permanent: any non-zero SOCKS5 REP code. Includes 0x05 refused,
	// 0x07 cmd unsupported, 0x02 not allowed by ruleset — none of which
	// retry will fix.
	var rep ErrSocks5Reply
	if errors.As(err, &rep) {
		return ClassificationPermanent
	}

	// Permanent: STUN sentinels (malformed responses, missing attrs).
	switch {
	case errors.Is(err, ErrSTUNTooShort),
		errors.Is(err, ErrSTUNBadMagicCookie),
		errors.Is(err, ErrSTUNNotSuccess),
		errors.Is(err, ErrSTUNTxIDMismatch),
		errors.Is(err, ErrSTUNNoMappedAddress),
		errors.Is(err, ErrSTUNUnsupportedFamily):
		return ClassificationPermanent
	}

	// Transient: ECONNRESET (peer hung up mid-stream).
	if errors.Is(err, syscall.ECONNRESET) {
		return ClassificationTransient
	}

	// Transient: DNS temporary failure or DNS timeout.
	var dnsErr *net.DNSError
	if errors.As(err, &dnsErr) {
		if dnsErr.IsTemporary || dnsErr.IsTimeout {
			return ClassificationTransient
		}
		return ClassificationPermanent
	}

	// Transient: io.ErrUnexpectedEOF wrapped inside a net.OpError. Bare
	// io.ErrUnexpectedEOF (synthesised by our SOCKS5 readers) is a
	// malformed-reply signal and stays permanent.
	var opErr *net.OpError
	if errors.As(err, &opErr) {
		if errors.Is(opErr.Err, io.ErrUnexpectedEOF) {
			return ClassificationTransient
		}
	}

	// Transient: net.Error.Timeout(). Checked AFTER the typed sentinels
	// so that a timeout-shaped error wrapping a permanent sentinel still
	// classifies permanent.
	var ne net.Error
	if errors.As(err, &ne) && ne.Timeout() {
		return ClassificationTransient
	}

	return ClassificationPermanent
}

// isContextErr returns true when err's chain contains context.Canceled
// or context.DeadlineExceeded. Used by checker.go to label cancelled
// tests as Error="cancelled".
func isContextErr(err error) bool {
	if err == nil {
		return false
	}
	return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
