package diagnostics

import (
	"context"
	"errors"
	"net"
	"os/exec"
	"strings"
	"syscall"
)

// Classify maps a raw error to a stable Code. It prefers typed-error inspection
// via errors.Is / errors.As over string matching; falls back to string matching
// only when the underlying library does not expose structured error types.
//
// The hints parameter lets callers nudge the classifier with context ("this
// error came from the stdio spawn path", etc.).
//
// If no specific classification applies, Classify returns UnknownUnclassified.
func Classify(err error, hints ClassifierHints) Code {
	if err == nil {
		return ""
	}

	// Fast path: a producer opted into explicit code attribution via
	// WrapError / CodedError. This is how OAUTH/DOCKER/CONFIG/QUARANTINE
	// producers bypass free-text matching for their terminal errors.
	var coded interface{ Code() Code }
	if errors.As(err, &coded) {
		if c := coded.Code(); c != "" {
			return c
		}
	}

	if c := classifyStdio(err, hints); c != "" {
		return c
	}
	if c := classifyHTTP(err, hints); c != "" {
		return c
	}
	if c := classifyNetwork(err, hints); c != "" {
		return c
	}
	if c := classifyOAuth(err, hints); c != "" {
		return c
	}
	if c := classifyDocker(err, hints); c != "" {
		return c
	}
	if c := classifyConfig(err, hints); c != "" {
		return c
	}
	if c := classifyQuarantine(err, hints); c != "" {
		return c
	}

	return UnknownUnclassified
}

// classifyOAuth recognises OAuth 2.1 / PKCE failure surface-strings emitted
// by the upstream manager and mcp-go. Producers that want deterministic
// classification should wrap their terminal error with WrapError(code, err).
func classifyOAuth(err error, _ ClassifierHints) Code {
	msg := strings.ToLower(err.Error())
	switch {
	// Re-auth (a previously-working stored token broke) is matched BEFORE the
	// login-required backstop because "re-login available" contains the
	// "login available" substring; the order of these two cases is load-bearing.
	case strings.Contains(msg, "re-login available"),
		strings.Contains(msg, "re-authentication required"),
		strings.Contains(msg, "server error with stored token"):
		return OAuthReauthRequired
	// First-time sign-in deferred to the user (ErrOAuthPending text). These are
	// actionable user-states, not faults — keep them out of UNKNOWN.
	case strings.Contains(msg, "oauth authentication required"),
		strings.Contains(msg, "login available"),
		strings.Contains(msg, "mcpproxy auth login"):
		return OAuthLoginRequired
	case strings.Contains(msg, "refresh_token") && strings.Contains(msg, "expired"),
		strings.Contains(msg, "refresh token has expired"),
		strings.Contains(msg, "refresh token is expired"):
		return OAuthRefreshExpired
	case strings.Contains(msg, "refresh") && strings.Contains(msg, "403"),
		strings.Contains(msg, "refresh") && strings.Contains(msg, "invalid_grant"):
		return OAuthRefresh403
	case strings.Contains(msg, "oauth metadata unavailable"),
		strings.Contains(msg, "oauth discovery failed"),
		strings.Contains(msg, "discover") && strings.Contains(msg, "oauth"),
		strings.Contains(msg, ".well-known/oauth"):
		return OAuthDiscoveryFailed
	case strings.Contains(msg, "oauth callback") && strings.Contains(msg, "timeout"),
		strings.Contains(msg, "authorization timeout"):
		return OAuthCallbackTimeout
	case strings.Contains(msg, "redirect_uri") && strings.Contains(msg, "mismatch"),
		strings.Contains(msg, "redirect uri") && strings.Contains(msg, "mismatch"):
		return OAuthCallbackMismatch
	}
	return ""
}

// classifyDocker recognises common Docker isolation failures the runtime
// currently reports as plain errors. Typed opt-in via WrapError is still
// preferred; these string matches are the last-resort fallback.
func classifyDocker(err error, _ ClassifierHints) Code {
	msg := strings.ToLower(err.Error())
	switch {
	case strings.Contains(msg, "cannot connect to the docker daemon"),
		strings.Contains(msg, "is the docker daemon running"),
		strings.Contains(msg, "docker daemon is not reachable"),
		strings.Contains(msg, "docker.sock: connect: no such file"):
		return DockerDaemonDown
	case strings.Contains(msg, "snap") && strings.Contains(msg, "apparmor"),
		strings.Contains(msg, "no-new-privileges") && strings.Contains(msg, "apparmor"):
		return DockerSnapAppArmor
	case strings.Contains(msg, "permission denied") && strings.Contains(msg, "docker"),
		strings.Contains(msg, "got permission denied while trying to connect to the docker"):
		return DockerNoPermission
	case strings.Contains(msg, "pull access denied"),
		strings.Contains(msg, "docker") && strings.Contains(msg, "image") && strings.Contains(msg, "pull") && strings.Contains(msg, "fail"),
		strings.Contains(msg, "manifest unknown"):
		return DockerImagePullFailed
	// docker CLI unresolved (#696). These shapes are unambiguous about the
	// docker BINARY being missing, so they classify even without the
	// DockerIsolated hint (e.g. shellwrap's resolution-failure error).
	case strings.Contains(msg, "docker not found in path"),
		strings.Contains(msg, "docker not found in login shell"),
		strings.Contains(msg, "docker: command not found"),
		strings.Contains(msg, "command not found: docker"), // zsh: "zsh:1: command not found: docker"
		strings.Contains(msg, "docker: not found"),
		strings.Contains(msg, `"docker": executable file not found`):
		return DockerCLINotFound
	// OCI runtime failures from `docker run`. NOTE: a BARE "exec format error"
	// is intentionally NOT matched here — a non-docker, wrong-architecture host
	// stdio binary emits the same string and must stay STDIO-classified. The
	// docker-isolated path routes bare "exec format error" via the hinted
	// classifyDockerIsolatedSpawn; here we require real OCI/runc context.
	case strings.Contains(msg, "oci runtime"),
		strings.Contains(msg, "runc"):
		return DockerOCIRuntime
	}
	return ""
}

// classifyConfig recognises configuration parsing / secret resolution failures.
func classifyConfig(err error, _ ClassifierHints) Code {
	msg := strings.ToLower(err.Error())
	switch {
	case strings.Contains(msg, "deprecated") && strings.Contains(msg, "field"),
		strings.Contains(msg, "deprecated configuration"):
		return ConfigDeprecatedField
	case strings.Contains(msg, "unmarshal") && strings.Contains(msg, "config"),
		strings.Contains(msg, "config parse"),
		strings.Contains(msg, "invalid config"),
		strings.Contains(msg, "config: ") && (strings.Contains(msg, "json") || strings.Contains(msg, "yaml") || strings.Contains(msg, "toml")):
		return ConfigParseError
	case strings.Contains(msg, "missing secret"),
		strings.Contains(msg, "secret reference") && (strings.Contains(msg, "not found") || strings.Contains(msg, "unresolved")),
		strings.Contains(msg, "unresolved secret"):
		return ConfigMissingSecret
	}
	return ""
}

// classifyQuarantine recognises security-quarantine rejections.
func classifyQuarantine(err error, _ ClassifierHints) Code {
	msg := strings.ToLower(err.Error())
	switch {
	case strings.Contains(msg, "quarantine") && (strings.Contains(msg, "pending") || strings.Contains(msg, "requires approval") || strings.Contains(msg, "not approved")):
		return QuarantinePendingApproval
	case strings.Contains(msg, "tool") && strings.Contains(msg, "changed") && (strings.Contains(msg, "re-approval") || strings.Contains(msg, "reapprove") || strings.Contains(msg, "rug pull")):
		return QuarantineToolChanged
	}
	return ""
}

// classifyDockerIsolatedSpawn maps a spawn/exec failure on a Docker-isolated
// server to a specific DOCKER code. Returns "" when the error is not a
// recognised docker-isolation failure (caller falls through to generic stdio
// handling).
//
// Case order is load-bearing:
//  1. The docker BINARY itself is missing (#696) — must win even though its
//     message also contains "command not found" / "executable file not found".
//  2. The in-container interpreter is missing — real docker output nests this
//     inside an "OCI runtime create failed: … exec: \"x\": executable file not
//     found" string, so it must be checked BEFORE the generic OCI case below.
//  3. Any other OCI runtime failure (exec format error / runc).
func classifyDockerIsolatedSpawn(err error) Code {
	// Host couldn't even start the docker binary (direct exec path).
	var execErr *exec.Error
	if errors.As(err, &execErr) && errors.Is(execErr.Err, syscall.ENOENT) &&
		strings.Contains(strings.ToLower(execErr.Name), "docker") {
		return DockerCLINotFound
	}

	msg := strings.ToLower(err.Error())
	switch {
	// (1) docker binary unresolved: shellwrap resolution failure, or the shell
	// / Go exec layer reporting `docker` itself missing. Cover both shell
	// wordings: bash/sh `docker: command not found` AND zsh's reversed
	// `zsh:1: command not found: docker` (the common macOS login-shell shape) —
	// the latter must beat the generic "command not found" → EXEC case below.
	case strings.Contains(msg, `"docker": executable file not found`),
		strings.Contains(msg, "docker: command not found"),
		strings.Contains(msg, "command not found: docker"),
		strings.Contains(msg, "docker: not found"),
		strings.Contains(msg, "docker not found in path"),
		strings.Contains(msg, "docker not found in login shell"):
		return DockerCLINotFound
	// (2) in-container interpreter missing (image lacks uvx/node/python/…).
	case strings.Contains(msg, "executable file not found"),
		strings.Contains(msg, "no such file or directory"),
		strings.Contains(msg, "command not found"):
		return DockerExecNotFound
	// (3) other OCI runtime failures (arch mismatch, runc start failure).
	case strings.Contains(msg, "oci runtime"),
		strings.Contains(msg, "exec format error"),
		strings.Contains(msg, "runc"):
		return DockerOCIRuntime
	}
	return ""
}

// classifyStdio handles os/exec spawn errors and handshake failures.
func classifyStdio(err error, hints ClassifierHints) Code {
	// Docker-isolated servers run `docker run …` over the stdio transport, so
	// ENOENT-class failures here are docker-specific (#696 CLI missing, or an
	// image/interpreter mismatch) rather than a plain host-binary miss. Resolve
	// those to DOCKER codes before the generic stdio matching below.
	if hints.DockerIsolated {
		if c := classifyDockerIsolatedSpawn(err); c != "" {
			return c
		}
	}

	var execErr *exec.Error
	if errors.As(err, &execErr) {
		// exec.Error wraps os.PathError which wraps syscall.Errno; ENOENT/EACCES
		// are the two we care about.
		if errors.Is(execErr.Err, syscall.ENOENT) {
			return STDIOSpawnENOENT
		}
		if errors.Is(execErr.Err, syscall.EACCES) {
			return STDIOSpawnEACCES
		}
		if errors.Is(execErr.Err, syscall.ENOEXEC) {
			return STDIOSpawnExecFormat
		}
	}

	// exec.ExitError — process started but exited non-zero during handshake.
	var exitErr *exec.ExitError
	if errors.As(err, &exitErr) {
		return STDIOExitNonzero
	}

	// Context deadline during handshake → handshake timeout. Only when the
	// hints say we're on the stdio transport (otherwise a generic timeout
	// would be misclassified).
	if hints.Transport == "stdio" && errors.Is(err, context.DeadlineExceeded) {
		return STDIOHandshakeTimeout
	}

	// String-match fallback for stdio failures when the raw error was
	// wrapped by an intermediate layer (e.g. "failed to connect: stdio
	// transport ... recent stderr: no such file or directory"). The upstream
	// manager currently string-wraps spawn failures, so we can't rely on
	// exec.Error being present. These matches are intentionally broad and
	// err toward MCPX_STDIO_SPAWN_ENOENT / MCPX_STDIO_HANDSHAKE_TIMEOUT —
	// both are strictly better than MCPX_UNKNOWN_UNCLASSIFIED for the user.
	if hints.Transport == "stdio" {
		msg := err.Error()
		lmsg := strings.ToLower(msg)
		switch {
		// Wrong-arch / non-executable host binary (ENOEXEC). Guarded against
		// docker OCI context ("oci runtime"/"runc") so a real containerized
		// exec-format failure still falls through to classifyDocker → OCI; a
		// BARE "exec format error" is a host stdio problem, not a Docker one.
		case strings.Contains(lmsg, "exec format error") &&
			!strings.Contains(lmsg, "oci runtime") && !strings.Contains(lmsg, "runc"):
			return STDIOSpawnExecFormat
		case strings.Contains(lmsg, "no such file or directory"),
			strings.Contains(lmsg, "executable file not found"),
			strings.Contains(lmsg, "command not found"):
			return STDIOSpawnENOENT
		case strings.Contains(lmsg, "permission denied"):
			return STDIOSpawnEACCES
		// Subprocess started but the transport closed before the MCP initialize
		// handshake completed — the child exited early (e.g. printed a fatal
		// config error to stderr and died). mcp-go surfaces this as a closed
		// transport, which otherwise falls through to MCPX_UNKNOWN_UNCLASSIFIED
		// even though the real cause is on the child's stderr (MCP-1093 / #599).
		// Gated on the stdio hint so a "transport closed" from another transport
		// is not misattributed.
		case strings.Contains(lmsg, "transport closed"),
			strings.Contains(lmsg, "exited before completing the mcp initialize"),
			strings.Contains(lmsg, "exited before the mcp initialize"):
			return STDIOExitBeforeInitialize
		case strings.Contains(lmsg, "did not respond to mcp initialize"),
			strings.Contains(lmsg, "handshake timeout"):
			return STDIOHandshakeTimeout
		case strings.Contains(lmsg, "invalid handshake"),
			strings.Contains(lmsg, "malformed"):
			return STDIOHandshakeInvalid
		}
	}

	return ""
}

// classifyHTTP handles HTTP/SSE transport errors including TLS, DNS, and
// structured HTTP status errors. HTTP status classification prefers a typed
// statusError (DiagnoseHTTPStatus below) but also falls back to a string match
// because the upstream layer commonly stringifies the error before bubbling it
// up.
func classifyHTTP(err error, hints ClassifierHints) Code {
	// DNS lookup errors are reported as *net.DNSError.
	var dnsErr *net.DNSError
	if errors.As(err, &dnsErr) {
		return HTTPDNSFailed
	}

	// TLS verification: mcp-go surfaces these as *tls.CertificateVerificationError
	// in recent releases; we avoid a direct import dependency by string match.
	msg := err.Error()
	lmsg := strings.ToLower(msg)
	if strings.Contains(msg, "x509:") || strings.Contains(msg, "tls: ") || strings.Contains(msg, "certificate") {
		return HTTPTLSFailed
	}

	// Connection refused — syscall.ECONNREFUSED wrapped by net.OpError.
	if errors.Is(err, syscall.ECONNREFUSED) {
		return HTTPConnRefuse
	}

	// HTTP request timeouts. The upstream HTTP transport bubbles
	// context.DeadlineExceeded up wrapped in a free-text "transport error: ...
	// context deadline exceeded" string. Try the typed errors.Is path first
	// (cheap, exact); fall back to substring on the http transport hint to
	// catch the stringified form. Without this, hf.co/mcp slowdowns surface
	// to the UI as MCPX_UNKNOWN_UNCLASSIFIED.
	if errors.Is(err, context.DeadlineExceeded) && hints.Transport == "http" {
		return HTTPTimeout
	}
	if hints.Transport == "http" && strings.Contains(lmsg, "context deadline exceeded") {
		return HTTPTimeout
	}

	// HTTP status text fallback. The upstream layer wraps non-2xx responses
	// as a plain string ("transport error: request failed with status 504: ...").
	// The typed statusError path used by DiagnoseHTTPStatus() never fires for
	// those, so we substring-match the canonical phrasing here.
	if hints.Transport == "http" {
		if code := matchHTTPStatusText(lmsg); code != "" {
			return code
		}
	}

	return ""
}

// matchHTTPStatusText extracts a status code from the canonical
// "request failed with status NNN" / "notification failed with status NNN"
// phrasing emitted by the HTTP transport adapter. Returns empty when no
// recognised status appears.
func matchHTTPStatusText(lmsg string) Code {
	const marker = "status "
	idx := strings.Index(lmsg, marker)
	for idx != -1 {
		rest := lmsg[idx+len(marker):]
		// Need at least three digits.
		if len(rest) >= 3 && isDigit(rest[0]) && isDigit(rest[1]) && isDigit(rest[2]) {
			status := int(rest[0]-'0')*100 + int(rest[1]-'0')*10 + int(rest[2]-'0')
			if c := DiagnoseHTTPStatus(status); c != "" {
				return c
			}
		}
		next := strings.Index(lmsg[idx+1:], marker)
		if next == -1 {
			break
		}
		idx += 1 + next
	}
	return ""
}

func isDigit(b byte) bool { return b >= '0' && b <= '9' }

// classifyNetwork handles host-environment network issues.
func classifyNetwork(err error, hints ClassifierHints) Code {
	_ = hints
	var opErr *net.OpError
	if errors.As(err, &opErr) {
		// "network is unreachable" / "no route to host"
		if errors.Is(opErr.Err, syscall.ENETUNREACH) || errors.Is(opErr.Err, syscall.EHOSTUNREACH) {
			return NetworkOffline
		}
	}
	return ""
}

// DiagnoseHTTPStatus maps an HTTP status code to a Code. Returns empty if
// the status is not a known failure.
func DiagnoseHTTPStatus(status int) Code {
	switch {
	case status == 401:
		return HTTPUnauth
	case status == 403:
		return HTTPForbidden
	case status == 404:
		return HTTPNotFound
	case status >= 500 && status <= 599:
		return HTTPServerErr
	}
	return ""
}
