package api

import (
	"bytes"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/tidbcloud/aisupport/services/oncall/db"
	"github.com/tidbcloud/aisupport/services/oncall/models"
	"github.com/tidbcloud/aisupport/services/oncall/service"

	"github.com/gin-gonic/gin"
)

type bodyLogWriter struct {
	gin.ResponseWriter
	body *bytes.Buffer
}

func (w bodyLogWriter) Write(b []byte) (int, error) {
	w.body.Write(b)
	return w.ResponseWriter.Write(b)
}

func isReadMethod(method string) bool {
	return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions
}

func isHexString(s string) bool {
	for _, r := range s {
		if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
			return false
		}
	}
	return true
}

func buildActionName(method, path string) string {
	cleanPath := strings.Trim(path, "/")
	cleanPath = strings.ReplaceAll(cleanPath, "/", "_")
	cleanPath = strings.ReplaceAll(cleanPath, "-", "_")
	if cleanPath == "" {
		cleanPath = "root"
	}
	return strings.ToUpper(method) + "_" + cleanPath
}

func extractTeamIDFromRequest(path string, body []byte, query url.Values) uint {
	// REST routes carry the team id in the path: /api/v1/teams/{id}/...
	// (a numeric segment; slug names are resolved elsewhere and not audited here).
	if idx := strings.Index(path, "/teams/"); idx >= 0 {
		rest := path[idx+len("/teams/"):]
		if slash := strings.IndexByte(rest, '/'); slash >= 0 {
			rest = rest[:slash]
		}
		if id, err := strconv.ParseUint(strings.TrimSpace(rest), 10, 64); err == nil && id > 0 {
			return uint(id)
		}
	}

	if strings.Contains(path, "/team/") || strings.Contains(path, "/schedule/") {
		if idStr := query.Get("team_id"); idStr != "" {
			if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 64); err == nil {
				return uint(id)
			}
		}
	}

	if len(body) == 0 {
		if idStr := query.Get("id"); idStr != "" {
			if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 64); err == nil {
				return uint(id)
			}
		}
		if idStr := query.Get("team_id"); idStr != "" {
			if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 64); err == nil {
				return uint(id)
			}
		}
		return 0
	}

	var m map[string]interface{}
	if err := json.Unmarshal(body, &m); err != nil {
		return 0
	}

	if v, ok := m["team_id"]; ok {
		switch vv := v.(type) {
		case float64:
			if vv > 0 {
				return uint(vv)
			}
		case int:
			if vv > 0 {
				return uint(vv)
			}
		}
	}

	return 0
}

func shouldSkipAuth(path string, method string) bool {
	if method == http.MethodOptions {
		return true
	}
	if strings.HasPrefix(path, "/loginCB") {
		return true
	}
	// Logout clears the cookie itself and must succeed even with an expired session.
	if path == service.LegacyAPIBasePath+"/auth/logout" || path == service.SharedAPIBasePath+"/auth/logout" {
		return true
	}
	// The login exchange trades an Auth0 access token (in the request body) for a
	// session. It must run even when the request still carries a stale/invalid
	// session cookie — otherwise a dead cookie (e.g. after the DB is reset) would
	// be validated first, fail, and block re-login entirely.
	if method == http.MethodPost && (path == service.LegacyAPIBasePath+"/auth/token" || path == service.SharedAPIBasePath+"/auth/token") {
		return true
	}
	// The SPA bundle is public (it is not secret; the API behind it is authed).
	// This mirrors the previous nginx-served frontend, which required no token.
	if path == "/" || path == "/oncall" || strings.HasPrefix(path, "/oncall/") {
		return true
	}
	return false
}

func AuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		if shouldSkipAuth(c.Request.URL.Path, c.Request.Method) {
			c.Next()
			return
		}

		if service.ShouldBypassAuth() {
			email := strings.ToLower(strings.TrimSpace(c.GetHeader("X-User-Email")))
			// Strict-by-default in auth-bypass mode: if Auth0 validation is disabled but no explicit
			// bypass flag is set, do NOT auto-inject admin identity. This keeps 401/403
			// permission testing valid in dev.
			if email == "" && service.IsDevBypassEnabled() {
				email = service.BypassUserEmail()
			}
			if email == "" {
				respondError(c, http.StatusUnauthorized, ErrorCodeUnauthenticated, "missing authenticated user identity in bypass mode")
				c.Abort()
				return
			}
			name := strings.Split(email, "@")[0]
			c.Set("request_user_email", email)
			c.Set("request_user_name", name)
			c.Next()
			return
		}

		authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
		token := ""
		if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
			token = strings.TrimSpace(authHeader[7:])
		}

		if token == "" {
			token = strings.TrimSpace(c.Query("id_token"))
		}

		// Browser sessions are carried in an HttpOnly cookie (not readable by JS).
		if token == "" {
			if ck, err := c.Cookie(service.SessionCookieName); err == nil {
				token = strings.TrimSpace(ck)
			}
		}

		if token == "" {
			respondError(c, http.StatusUnauthorized, ErrorCodeUnauthenticated, "missing Authorization bearer token")
			c.Abort()
			return
		}

		// API Key Validation: keys are stored hashed, looked up by sha256 of the token.
		if strings.HasPrefix(token, "oc_v2_") {
			if apiKey, lookupErr := service.FindApiKeyByPlaintext(token); lookupErr == nil && apiKey.ID != 0 {
				email := strings.ToLower(strings.TrimSpace(apiKey.Email))
				if !service.IsEmailAllowed(email) {
					respondError(c, http.StatusForbidden, ErrorCodePermissionDenied, "email domain not allowed")
					c.Abort()
					return
				}
				name := strings.Split(email, "@")[0]
				if apiKey.Type == models.ApiKeyTypeService && strings.TrimSpace(apiKey.Name) != "" {
					name = apiKey.Name
				}

				c.Set("request_user_email", email)
				c.Set("request_user_name", name)
				c.Set("principal_type", apiKey.Type)
				c.Set("api_scopes", apiKey.Scopes)
				c.Set("id_token", token)
				c.Next()
				return
			}
		}

		// Session Token Validation (32 hex chars)
		if len(token) == 64 && isHexString(token) {
			var session models.UserSession
			if err := db.DB.Preload("User").Where("token = ? AND expires_at > ?", token, time.Now()).First(&session).Error; err == nil {
				email := strings.ToLower(strings.TrimSpace(session.User.Email))
				if !service.IsEmailAllowed(email) {
					respondError(c, http.StatusForbidden, ErrorCodePermissionDenied, "email domain not allowed")
					c.Abort()
					return
				}
				name := session.User.Name
				if name == "" {
					name = strings.Split(email, "@")[0]
				}

				// Sliding expiration: keep active users logged in.
				service.RenewSessionIfNeeded(c, &session)

				c.Set("request_user_email", email)
				c.Set("request_user_name", name)
				c.Set("id_token", token)
				c.Next()
				return
			}
		}

		// Try Auth0 access token
		email, name, err := service.ValidateAuth0AccessToken(token)
		if err == nil {
			if !service.IsEmailAllowed(email) {
				respondError(c, http.StatusForbidden, ErrorCodePermissionDenied, "email domain not allowed")
				c.Abort()
				return
			}
			c.Set("request_user_email", email)
			c.Set("request_user_name", name)
			c.Set("id_token", token)
			c.Next()
			return
		}

		respondError(c, http.StatusUnauthorized, ErrorCodeUnauthenticated, "invalid token")
		c.Abort()
		return
	}
}

// InternalNoAuthMiddleware supplies a request identity for the internal-only
// listener without validating a credential. The listener is exposed through a
// separate ClusterIP service and must not be attached to an Ingress.
//
// Callers may claim an application identity with X-User-Email so existing
// business-level permission and audit code keeps working. Requests that do not
// need a user-specific identity use a stable service principal.
func InternalNoAuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		email := strings.ToLower(strings.TrimSpace(c.GetHeader("X-User-Email")))
		if email == "" {
			email = "oncall-internal@pingcap.com"
		}
		name := strings.TrimSpace(c.GetHeader("X-Operator"))
		if name == "" {
			name = strings.Split(email, "@")[0]
		}

		c.Set("request_user_email", email)
		c.Set("request_user_name", name)
		c.Set("principal_type", models.ApiKeyTypeService)
		c.Set("api_scopes", service.ScopeOncallRead+" "+service.ScopeOncallWrite)
		c.Next()
	}
}

// ServiceKeyScopeGuard enforces least privilege for service (machine-to-machine)
// API keys. User/session/Auth0 principals are unaffected. A service key must hold
// oncall:read at minimum, and may only perform write operations if it also holds
// oncall:write. This makes service keys read-only by default.
func ServiceKeyScopeGuard() gin.HandlerFunc {
	return func(c *gin.Context) {
		if c.GetString("principal_type") != models.ApiKeyTypeService {
			c.Next()
			return
		}
		scopes := c.GetString("api_scopes")
		if !service.ScopeContains(scopes, service.ScopeOncallRead) {
			respondError(c, http.StatusForbidden, ErrorCodePermissionDenied, "service key missing required scope: "+service.ScopeOncallRead)
			c.Abort()
			return
		}
		if !isReadMethod(c.Request.Method) && !service.ScopeContains(scopes, service.ScopeOncallWrite) {
			respondError(c, http.StatusForbidden, ErrorCodePermissionDenied, "service key is read-only (missing scope: "+service.ScopeOncallWrite+")")
			c.Abort()
			return
		}
		c.Next()
	}
}

func AuditMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		if isReadMethod(c.Request.Method) {
			c.Next()
			return
		}

		requestBodyBytes, _ := io.ReadAll(c.Request.Body)
		c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBodyBytes))

		blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
		c.Writer = blw

		start := time.Now()
		c.Next()

		userEmail := strings.TrimSpace(c.GetString("request_user_email"))
		userName := strings.TrimSpace(c.GetString("request_user_name"))

		statusCode := c.Writer.Status()
		success := statusCode >= 200 && statusCode < 400
		errMsg := ""
		if len(c.Errors) > 0 {
			errMsg = c.Errors.String()
		} else if !success {
			errMsg = strings.TrimSpace(blw.body.String())
		}

		audit := models.AuditLog{
			RequestID:    start.Format("20060102150405.000000000"),
			UserEmail:    userEmail,
			UserName:     userName,
			Method:       c.Request.Method,
			Path:         c.Request.URL.Path,
			Action:       buildActionName(c.Request.Method, c.Request.URL.Path),
			TeamID:       extractTeamIDFromRequest(c.Request.URL.Path, requestBodyBytes, c.Request.URL.Query()),
			StatusCode:   statusCode,
			Success:      success,
			RequestBody:  string(requestBodyBytes),
			ResponseBody: blw.body.String(),
			ErrorMessage: errMsg,
		}

		if err := db.DB.Create(&audit).Error; err != nil {
			slog.Error("failed to persist audit log", "error", err)
		}
	}
}
