// Package health provides unified health status calculation for upstream MCP servers.
package health

import (
	"fmt"
	"strings"
	"time"

	"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
	"github.com/smart-mcp-proxy/mcpproxy-go/internal/stringutil"
)

// RefreshState represents the current state of token refresh for health reporting.
// Mirrors oauth.RefreshState for decoupling.
type RefreshState int

const (
	// RefreshStateIdle means no refresh is pending or in progress.
	RefreshStateIdle RefreshState = iota
	// RefreshStateScheduled means a proactive refresh is scheduled at 80% lifetime.
	RefreshStateScheduled
	// RefreshStateRetrying means refresh failed and is retrying with exponential backoff.
	RefreshStateRetrying
	// RefreshStateFailed means refresh permanently failed (e.g., invalid_grant).
	RefreshStateFailed
)

// HealthCalculatorInput contains all fields needed to calculate health status.
// This struct normalizes data from different sources (StateView, storage, config).
type HealthCalculatorInput struct {
	// Server identification
	Name string

	// Admin state
	Enabled     bool
	Quarantined bool

	// Connection state
	State     string // "connected", "connecting", "error", "idle", "disconnected"
	Connected bool
	LastError string

	// OAuth state (only for OAuth-enabled servers)
	OAuthRequired   bool
	OAuthStatus     string     // "authenticated", "expired", "error", "none"
	TokenExpiresAt  *time.Time // When token expires
	HasRefreshToken bool       // True if refresh token exists
	UserLoggedOut   bool       // True if user explicitly logged out

	// CallTimeOAuthRequired is set when a server connected anonymously (no config
	// OAuth) and listed tools successfully, but a tool call returned an
	// "authorization required" / 401 — i.e. the endpoint enforces OAuth only at
	// tools/call time (e.g. Google's sqladmin MCP). The managed client records
	// this on a call-time auth failure so we can surface a proactive Sign-in CTA
	// instead of reporting the server as fully healthy. MCP-2084.
	CallTimeOAuthRequired bool

	// Secret/config detection
	MissingSecret  string // Secret name if unresolved (e.g., "GITHUB_TOKEN")
	OAuthConfigErr string // OAuth config error (e.g., "requires 'resource' parameter")

	// Tool info
	ToolCount int

	// Refresh state (for health status integration - Spec 023)
	RefreshState       RefreshState // Current refresh state from RefreshManager
	RefreshRetryCount  int          // Number of retry attempts
	RefreshLastError   string       // Human-readable error message
	RefreshNextAttempt *time.Time   // When next retry will occur
}

// HealthCalculatorConfig contains configurable thresholds for health calculation.
type HealthCalculatorConfig struct {
	// ExpiryWarningDuration is the duration before token expiry to show degraded status.
	// Default: 1 hour
	ExpiryWarningDuration time.Duration
}

// DefaultHealthConfig returns the default health calculator configuration.
func DefaultHealthConfig() *HealthCalculatorConfig {
	return &HealthCalculatorConfig{
		ExpiryWarningDuration: time.Hour,
	}
}

// CalculateHealth calculates the unified health status for a server.
// The algorithm uses a priority-based approach where admin state is checked first,
// followed by connection state, then OAuth state.
func CalculateHealth(input HealthCalculatorInput, cfg *HealthCalculatorConfig) *contracts.HealthStatus {
	if cfg == nil {
		cfg = DefaultHealthConfig()
	}

	// 1. Admin state checks - these short-circuit health calculation
	if !input.Enabled {
		return &contracts.HealthStatus{
			Level:      LevelHealthy, // Disabled is intentional, not broken
			AdminState: StateDisabled,
			Summary:    "Disabled",
			Action:     ActionEnable,
		}
	}

	if input.Quarantined {
		return &contracts.HealthStatus{
			Level:      LevelHealthy, // Quarantined is intentional, not broken
			AdminState: StateQuarantined,
			Summary:    "Quarantined for review",
			Action:     ActionApprove,
		}
	}

	// 2. Missing secret check
	if input.MissingSecret != "" {
		return &contracts.HealthStatus{
			Level:      LevelUnhealthy,
			AdminState: StateEnabled,
			Summary:    "Missing secret",
			Detail:     input.MissingSecret,
			Action:     ActionSetSecret,
		}
	}

	// 3. OAuth config error check
	if input.OAuthConfigErr != "" {
		return &contracts.HealthStatus{
			Level:      LevelUnhealthy,
			AdminState: StateEnabled,
			Summary:    "OAuth configuration error",
			Detail:     input.OAuthConfigErr,
			Action:     ActionConfigure,
		}
	}

	// 4. Connection state checks
	// Normalize state to lowercase for consistent matching
	// (ConnectionState.String() returns "Error", "Disconnected", etc.)
	state := strings.ToLower(input.State)
	switch state {
	case "error":
		// For OAuth-required servers with OAuth-related errors, suggest login instead of restart
		level := LevelUnhealthy
		action := ActionRestart
		summary := formatErrorSummary(input.LastError)
		if input.OAuthRequired && isOAuthRelatedError(input.LastError) {
			level, action, summary = oauthAttentionState(input.LastError)
		}
		return &contracts.HealthStatus{
			Level:      level,
			AdminState: StateEnabled,
			Summary:    summary,
			Detail:     input.LastError,
			Action:     action,
		}
	case "disconnected":
		level := LevelUnhealthy
		summary := "Disconnected"
		action := ActionRestart
		if input.LastError != "" {
			summary = formatErrorSummary(input.LastError)
			// For OAuth-required servers with OAuth-related errors, suggest login
			if input.OAuthRequired && isOAuthRelatedError(input.LastError) {
				level, action, summary = oauthAttentionState(input.LastError)
			}
		}
		return &contracts.HealthStatus{
			Level:      level,
			AdminState: StateEnabled,
			Summary:    summary,
			Detail:     input.LastError,
			Action:     action,
		}
	case "connecting", "idle":
		return &contracts.HealthStatus{
			Level:      LevelHealthy,
			AdminState: StateEnabled,
			Summary:    "Connecting...",
			Action:     ActionNone, // Will resolve on its own — not an attention item
		}
	}

	// 4b. Call-time OAuth requirement (MCP-2084). A server that connects
	// anonymously and lists tools fine, but whose tools/call returns
	// "authorization required", never sets OAuthRequired (no config OAuth) or a
	// connection LastError, so it would otherwise fall through to the healthy
	// branch below and look fully Ready. Surface a proactive amber Sign-in CTA
	// instead. Placed after the connection-state switch so genuine error/
	// disconnected/connecting states (which return above) always take priority.
	if input.CallTimeOAuthRequired {
		return &contracts.HealthStatus{
			Level:      LevelDegraded,
			AdminState: StateEnabled,
			Summary:    "Sign-in required",
			Detail:     "This server requires sign-in before its tools can be called.",
			Action:     ActionLogin,
		}
	}

	// 5. OAuth state checks (only for servers that require OAuth)
	if input.OAuthRequired {
		// User explicitly logged out - needs re-authentication
		if input.UserLoggedOut {
			return &contracts.HealthStatus{
				Level:      LevelUnhealthy,
				AdminState: StateEnabled,
				Summary:    "Logged out",
				Action:     ActionLogin,
			}
		}

		// Token expired
		if input.OAuthStatus == "expired" {
			return &contracts.HealthStatus{
				Level:      LevelUnhealthy,
				AdminState: StateEnabled,
				Summary:    "Token expired",
				Action:     ActionLogin,
			}
		}

		// OAuth error (but not expired)
		if input.OAuthStatus == "error" {
			return &contracts.HealthStatus{
				Level:      LevelUnhealthy,
				AdminState: StateEnabled,
				Summary:    "Authentication error",
				Detail:     input.LastError,
				Action:     ActionLogin,
			}
		}

		// Token expiring soon (only degraded if no refresh token for auto-refresh)
		if input.TokenExpiresAt != nil && !input.TokenExpiresAt.IsZero() {
			timeUntilExpiry := time.Until(*input.TokenExpiresAt)
			if timeUntilExpiry > 0 && timeUntilExpiry <= cfg.ExpiryWarningDuration {
				// If we have a refresh token, the system can auto-refresh - stay healthy
				if input.HasRefreshToken {
					// Token will be auto-refreshed, show healthy with tool count
					return &contracts.HealthStatus{
						Level:      LevelHealthy,
						AdminState: StateEnabled,
						Summary:    formatConnectedSummary(input.ToolCount),
						Action:     ActionNone,
					}
				}
				// No refresh token - user needs to re-authenticate soon
				// M-002: Include exact expiration time in Detail field
				return &contracts.HealthStatus{
					Level:      LevelDegraded,
					AdminState: StateEnabled,
					Summary:    formatExpiringTokenSummary(timeUntilExpiry),
					Detail:     fmt.Sprintf("Token expires at %s", input.TokenExpiresAt.Format(time.RFC3339)),
					Action:     ActionLogin,
				}
			}
		}

		// Token is not authenticated yet (none status)
		if input.OAuthStatus == "none" || input.OAuthStatus == "" {
			// Server requires OAuth but no token - needs login
			return &contracts.HealthStatus{
				Level:      LevelUnhealthy,
				AdminState: StateEnabled,
				Summary:    "Authentication required",
				Action:     ActionLogin,
			}
		}
	}

	// 6. Refresh state checks (Spec 023)
	// Check if refresh is in a degraded or failed state
	switch input.RefreshState {
	case RefreshStateRetrying:
		// Refresh failed but retrying - degraded status
		detail := formatRefreshRetryDetail(input.RefreshRetryCount, input.RefreshNextAttempt, input.RefreshLastError)
		return &contracts.HealthStatus{
			Level:      LevelDegraded,
			AdminState: StateEnabled,
			Summary:    "Token refresh pending",
			Detail:     detail,
			Action:     ActionViewLogs,
		}
	case RefreshStateFailed:
		// Refresh permanently failed - unhealthy status
		detail := "Re-authentication required"
		if input.RefreshLastError != "" {
			detail = fmt.Sprintf("Re-authentication required: %s", input.RefreshLastError)
		}
		return &contracts.HealthStatus{
			Level:      LevelUnhealthy,
			AdminState: StateEnabled,
			Summary:    "Refresh token expired",
			Detail:     detail,
			Action:     ActionLogin,
		}
	}

	// 7. Healthy state - connected with valid authentication (if required)
	return &contracts.HealthStatus{
		Level:      LevelHealthy,
		AdminState: StateEnabled,
		Summary:    formatConnectedSummary(input.ToolCount),
		Action:     ActionNone,
	}
}

// formatConnectedSummary formats the summary for a healthy connected server.
func formatConnectedSummary(toolCount int) string {
	if toolCount == 0 {
		return "Connected"
	}
	if toolCount == 1 {
		return "Connected (1 tool)"
	}
	return fmt.Sprintf("Connected (%d tools)", toolCount)
}

// formatErrorSummary formats an error message for the summary field.
// It truncates long errors and makes them more user-friendly.
func formatErrorSummary(lastError string) string {
	if lastError == "" {
		return "Connection error"
	}

	// Common error patterns to friendly messages.
	// Order matters: more specific patterns must come before generic ones.
	// For example, "no such host" must be checked before "dial tcp" since
	// DNS errors often appear as "dial tcp: no such host".
	errorMappings := []struct {
		pattern  string
		friendly string
	}{
		// Specific patterns first
		{"no such host", "Host not found"},
		{"connection refused", "Connection refused"},
		{"connection reset", "Connection reset"},
		{"timeout", "Connection timeout"},
		{"EOF", "Connection closed"},
		{"authentication failed", "Authentication failed"},
		{"unauthorized", "Unauthorized"},
		{"forbidden", "Access forbidden"},
		{"oauth", "OAuth error"},
		{"certificate", "Certificate error"},
		// Generic patterns last
		{"dial tcp", "Cannot connect"},
	}

	// Check for known patterns (in order)
	for _, mapping := range errorMappings {
		if stringutil.ContainsIgnoreCase(lastError, mapping.pattern) {
			return mapping.friendly
		}
	}

	// Truncate if too long (max 50 chars for summary)
	if len(lastError) > 50 {
		return lastError[:47] + "..."
	}
	return lastError
}

// formatExpiringTokenSummary formats the summary for an expiring token.
func formatExpiringTokenSummary(timeUntilExpiry time.Duration) string {
	if timeUntilExpiry < time.Minute {
		return "Token expiring now"
	}
	if timeUntilExpiry < time.Hour {
		minutes := int(timeUntilExpiry.Minutes())
		if minutes == 1 {
			return "Token expiring in 1m"
		}
		return fmt.Sprintf("Token expiring in %dm", minutes)
	}
	hours := int(timeUntilExpiry.Hours())
	if hours == 1 {
		return "Token expiring in 1h"
	}
	return fmt.Sprintf("Token expiring in %dh", hours)
}

// formatRefreshRetryDetail formats the detail message for a refresh retry state.
func formatRefreshRetryDetail(retryCount int, nextAttempt *time.Time, lastError string) string {
	var detail string

	// Start with retry count and next attempt time
	if nextAttempt != nil && !nextAttempt.IsZero() {
		detail = fmt.Sprintf("Refresh retry %d scheduled for %s", retryCount, nextAttempt.Format(time.RFC3339))
	} else {
		detail = fmt.Sprintf("Refresh retry %d pending", retryCount)
	}

	// Add last error if available
	if lastError != "" {
		// Truncate error if too long
		errorMsg := lastError
		if len(errorMsg) > 100 {
			errorMsg = errorMsg[:97] + "..."
		}
		detail = fmt.Sprintf("%s: %s", detail, errorMsg)
	}

	return detail
}

// isOAuthRelatedError checks if the error message indicates an OAuth issue.
// Connection errors take precedence — when a server is simply offline,
// the mcp-go client wraps the connection error inside "authentication strategies failed",
// which would incorrectly trigger OAuth-related detection.
func isOAuthRelatedError(err string) bool {
	if err == "" {
		return false
	}
	// Connection errors take precedence - these are NOT OAuth issues
	// even if the error message also contains OAuth-related text
	connectionPatterns := []string{
		"connection refused",
		"connection reset",
		"no such host",
		"network is unreachable",
		"dial tcp",
		"i/o timeout",
		"context deadline exceeded",
		"no route to host",
	}
	for _, pattern := range connectionPatterns {
		if stringutil.ContainsIgnoreCase(err, pattern) {
			return false
		}
	}
	// Check for common OAuth-related error patterns
	oauthPatterns := []string{
		"oauth",
		"authentication required",
		"authentication strategies failed",
		"unauthorized",
		"login required",
		"token expired",
		"invalid_grant",
		"access_denied",
	}
	for _, pattern := range oauthPatterns {
		if stringutil.ContainsIgnoreCase(err, pattern) {
			return true
		}
	}
	return false
}

// oauthAttentionState maps an OAuth-related error into the health level, action,
// and summary the user should see. A first-time sign-in (ErrOAuthPending) is an
// expected setup step, so it surfaces as degraded/amber with "Sign-in required".
// A previously-working token that broke (re-auth) stays unhealthy/red because it
// is a regression from a working state. Both suggest the login action. MCP-1820.
//
// Callers MUST gate this behind isOAuthRelatedError so genuine connection
// failures (which mcp-go wraps in "authentication strategies failed" noise) are
// not downgraded to amber.
func oauthAttentionState(lastError string) (level, action, summary string) {
	// Only a first-time deferred sign-in (ErrOAuthPending) is amber. Re-auth and
	// every other genuine OAuth error (revoked token, invalid_grant, …) stays red
	// because the server was — or should have been — working.
	if isOAuthLoginRequiredError(lastError) {
		return LevelDegraded, ActionLogin, "Sign-in required"
	}
	return LevelUnhealthy, ActionLogin, "Authentication required"
}

// isOAuthLoginRequiredError reports whether an OAuth-related error is a
// first-time deferred sign-in (ErrOAuthPending). The user has never signed in,
// so it is an expected setup step (amber) rather than a broken state (red).
// Re-auth ("stored token broke") is explicitly excluded so it stays red. The
// markers mirror diagnostics.classifyOAuth's login backstops. MCP-1820.
func isOAuthLoginRequiredError(err string) bool {
	if isOAuthReauthError(err) {
		return false
	}
	loginPatterns := []string{
		"oauth authentication required",
		"login available",
		"mcpproxy auth login",
	}
	for _, pattern := range loginPatterns {
		if stringutil.ContainsIgnoreCase(err, pattern) {
			return true
		}
	}
	return false
}

// isOAuthReauthError reports whether an OAuth-related error indicates that a
// previously-working stored token broke and must be refreshed by signing in
// again (as opposed to a first-time sign-in). Matched against the ErrOAuthPending
// "stored token" / "re-login available" message text. MCP-1820.
func isOAuthReauthError(err string) bool {
	reauthPatterns := []string{
		"re-login available",
		"re-authentication required",
		"server error with stored token",
		"stored token may be invalid",
	}
	for _, pattern := range reauthPatterns {
		if stringutil.ContainsIgnoreCase(err, pattern) {
			return true
		}
	}
	return false
}

// ExtractMissingSecret extracts the secret name from an error message if the error
// indicates a missing secret reference (e.g., unresolved environment variable).
// Returns the secret name or empty string if the error is not about missing secrets.
func ExtractMissingSecret(lastError string) string {
	if lastError == "" {
		return ""
	}

	// Pattern: "environment variable VARNAME not found or empty"
	const prefix = "environment variable "
	const suffix = " not found or empty"
	if idx := findSubstring(lastError, prefix); idx >= 0 {
		start := idx + len(prefix)
		if endIdx := findSubstring(lastError[start:], suffix); endIdx > 0 {
			return lastError[start : start+endIdx]
		}
	}

	// Pattern: "${env:VARNAME}" unresolved
	const envPrefix = "${env:"
	if idx := findSubstring(lastError, envPrefix); idx >= 0 {
		start := idx + len(envPrefix)
		if endIdx := findChar(lastError[start:], '}'); endIdx > 0 {
			return lastError[start : start+endIdx]
		}
	}

	return ""
}

// ExtractOAuthConfigError extracts an OAuth configuration error from the error message.
// Returns the config error description or empty string if not an OAuth config issue.
func ExtractOAuthConfigError(lastError string) string {
	if lastError == "" {
		return ""
	}

	// OAuth config issues typically mention "resource" parameter or config validation
	configPatterns := []string{
		"requires 'resource' parameter",
		"missing client_id",
		"oauth config validation failed",
		"invalid oauth configuration",
	}

	for _, pattern := range configPatterns {
		if stringutil.ContainsIgnoreCase(lastError, pattern) {
			return lastError
		}
	}

	return ""
}

// findSubstring returns the index of substr in s, or -1 if not found.
func findSubstring(s, substr string) int {
	for i := 0; i <= len(s)-len(substr); i++ {
		if s[i:i+len(substr)] == substr {
			return i
		}
	}
	return -1
}

// findChar returns the index of ch in s, or -1 if not found.
func findChar(s string, ch byte) int {
	for i := 0; i < len(s); i++ {
		if s[i] == ch {
			return i
		}
	}
	return -1
}
