package auth

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"hyapp/pkg/appcode"
	"hyapp/pkg/idgen"
	"hyapp/pkg/logx"
	authdomain "hyapp/services/user-service/internal/domain/auth"
)

const revokeReasonGeoPolicyBlocked = "GEO_POLICY_BLOCKED"

// LoginIPRiskWorkerOptions 控制 cron-service 触发的单批 IP 风控处理边界。
type LoginIPRiskWorkerOptions struct {
	WorkerID  string
	LockTTL   time.Duration
	BatchSize int
}

type providerAttempt struct {
	Provider   string `json:"provider"`
	Order      int    `json:"order"`
	Country    string `json:"country"`
	Status     string `json:"status"`
	DurationMs int64  `json:"duration_ms"`
	ErrorCode  string `json:"error_code"`
}

func (s *Service) ProcessLoginIPRiskBatch(ctx context.Context, options LoginIPRiskWorkerOptions) (int, error) {
	if !s.loginRiskPolicy.Enabled || s.authRepository == nil {
		return 0, nil
	}
	options = normalizeLoginIPRiskWorkerOptions(options)
	nowMs := s.now().UnixMilli()
	jobs, err := s.authRepository.ClaimLoginIPRiskJobs(ctx, options.WorkerID, nowMs, options.LockTTL, options.BatchSize)
	if err != nil {
		return 0, err
	}
	for _, job := range jobs {
		jobCtx := appcode.WithContext(ctx, job.AppCode)
		if err := s.processLoginIPRiskJob(jobCtx, job); err != nil {
			logx.Error(jobCtx, "login_ip_risk_job_failed", err, slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("client_ip_hash", job.ClientIPHash))
			job.Status = authdomain.LoginIPRiskStatusFailed
			job.FailureReason = trimRiskFailure(err.Error())
			job.UpdatedAtMs = s.now().UnixMilli()
			_ = s.authRepository.CompleteLoginIPRiskJob(jobCtx, job)
		}
	}
	return len(jobs), nil
}

func normalizeLoginIPRiskWorkerOptions(options LoginIPRiskWorkerOptions) LoginIPRiskWorkerOptions {
	if strings.TrimSpace(options.WorkerID) == "" {
		options.WorkerID = "user-service"
	}
	if options.LockTTL <= 0 {
		options.LockTTL = 30 * time.Second
	}
	if options.BatchSize <= 0 {
		options.BatchSize = 100
	}
	return options
}

func (s *Service) processLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error {
	policy := s.loginRiskPolicy
	nowMs := s.now().UnixMilli()
	userWhitelisted, err := s.CheckLoginRiskUserWhitelisted(ctx, job.UserID)
	if err != nil {
		logx.Warn(ctx, "login_user_whitelist_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.Int64("user_id", job.UserID), slog.String("error", err.Error()))
	}
	if userWhitelisted {
		// 用户白名单是运营按真实 user_id 做的例外；命中后不再访问 GeoIP，也不写 IP 决策缓存，避免把用户级放行扩散到同 IP 其他账号。
		return s.applyLoginIPRiskDecision(ctx, job, IPDecision{
			Decision:    authdomain.LoginIPRiskDecisionAllowed,
			Source:      "user_whitelist",
			CheckedAtMs: nowMs,
			ExpiresAtMs: nowMs + loginRiskUserWhitelistCacheTTL.Milliseconds(),
		}, nil, authdomain.LoginIPRiskStatusCompleted)
	}
	whitelisted, err := s.CheckLoginRiskIPWhitelisted(ctx, job.ClientIP)
	if err != nil {
		logx.Warn(ctx, "login_ip_whitelist_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))
	}
	if whitelisted {
		// 白名单是运营显式放行，命中后不再访问 GeoIP provider，也不写 IP blocked 决策，避免后续登录被旧地区缓存误拦。
		return s.applyLoginIPRiskDecision(ctx, job, IPDecision{
			Decision:    authdomain.LoginIPRiskDecisionAllowed,
			Source:      "ip_whitelist",
			CheckedAtMs: nowMs,
			ExpiresAtMs: nowMs + loginRiskIPWhitelistCacheTTL.Milliseconds(),
		}, nil, authdomain.LoginIPRiskStatusCompleted)
	}
	if cached, ok := s.freshCachedIPDecision(ctx, job); ok {
		return s.applyLoginIPRiskDecision(ctx, job, cached, nil, authdomain.LoginIPRiskStatusSkipped)
	}

	result, attempts := s.resolveIPCountry(ctx, job, policy)
	decision := s.decisionForCountry(ctx, result.CountryCode)
	ttl := s.ttlForDecision(decision)
	expiresAtMs := nowMs + ttl.Milliseconds()
	ipDecision := IPDecision{
		Decision:    decision,
		Country:     result.CountryCode,
		Source:      "geo_provider_chain",
		CheckedAtMs: nowMs,
		ExpiresAtMs: expiresAtMs,
	}
	if s.ipDecisionCache != nil {
		if err := s.ipDecisionCache.SetIPDecision(ctx, job.AppCode, job.ClientIPHash, ipDecision, ttl); err != nil {
			logx.Warn(ctx, "login_ip_decision_cache_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))
		}
	}

	attemptsJSON, err := json.Marshal(attempts)
	if err != nil {
		return err
	}
	if err := s.authRepository.CreateLoginIPRiskDecision(ctx, authdomain.LoginIPRiskDecision{
		AppCode:              job.AppCode,
		DecisionID:           idgen.New("ipdecision"),
		ClientIP:             job.ClientIP,
		ClientIPHash:         job.ClientIPHash,
		Decision:             decision,
		CountryCode:          result.CountryCode,
		Confidence:           result.Confidence,
		ProviderAttemptsJSON: string(attemptsJSON),
		DecidedAtMs:          nowMs,
		ExpiresAtMs:          expiresAtMs,
		CreatedAtMs:          nowMs,
	}); err != nil {
		return err
	}

	return s.applyLoginIPRiskDecision(ctx, job, ipDecision, attempts, authdomain.LoginIPRiskStatusCompleted)
}

func (s *Service) freshCachedIPDecision(ctx context.Context, job authdomain.LoginIPRiskJob) (IPDecision, bool) {
	if s.ipDecisionCache == nil {
		return IPDecision{}, false
	}
	decision, ok, err := s.ipDecisionCache.GetIPDecision(ctx, job.AppCode, job.ClientIPHash)
	if err != nil {
		logx.Warn(ctx, "login_ip_decision_cache_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))
		return IPDecision{}, false
	}
	if !ok {
		return IPDecision{}, false
	}
	if decision.ExpiresAtMs > 0 && decision.ExpiresAtMs <= s.now().UnixMilli() {
		return IPDecision{}, false
	}
	if decision.Decision != authdomain.LoginIPRiskDecisionBlocked && decision.Decision != authdomain.LoginIPRiskDecisionAllowed {
		return IPDecision{}, false
	}
	return decision, true
}

func (s *Service) resolveIPCountry(ctx context.Context, job authdomain.LoginIPRiskJob, policy LoginRiskPolicy) (IPGeoResult, []providerAttempt) {
	providers := s.ipGeoProviders
	if len(providers) == 0 {
		providers = BuildIPGeoProviders(policy.ProviderNames)
	}
	attempts := make([]providerAttempt, 0, len(providers))
	for index, provider := range providers {
		started := time.Now()
		providerCtx, cancel := context.WithTimeout(ctx, policy.ProviderTimeout)
		result, err := provider.ResolveCountry(providerCtx, job)
		cancel()
		attempt := providerAttempt{
			Provider:   provider.Name(),
			Order:      index + 1,
			Country:    normalizeCountryCode(result.CountryCode),
			DurationMs: time.Since(started).Milliseconds(),
		}
		if err != nil {
			attempt.Status = "failed"
			attempt.ErrorCode = trimRiskFailure(err.Error())
			attempts = append(attempts, attempt)
			logx.Warn(ctx, "login_ip_geo_provider_attempt", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("provider", provider.Name()), slog.Int("order", index+1), slog.String("status", attempt.Status), slog.String("error_code", attempt.ErrorCode), slog.Int64("duration_ms", attempt.DurationMs))
			continue
		}
		if attempt.Country == "" {
			attempt.Status = "failed"
			attempt.ErrorCode = "empty_country"
			attempts = append(attempts, attempt)
			continue
		}
		attempt.Status = "ok"
		attempts = append(attempts, attempt)
		logx.Info(ctx, "login_ip_geo_provider_attempt", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("provider", provider.Name()), slog.Int("order", index+1), slog.String("country", attempt.Country), slog.String("status", attempt.Status), slog.Int64("duration_ms", attempt.DurationMs))
		return IPGeoResult{CountryCode: attempt.Country, Confidence: result.Confidence}, attempts
	}
	return IPGeoResult{}, attempts
}

func (s *Service) decisionForCountry(ctx context.Context, countryCode string) string {
	countryCode = normalizeCountryCode(countryCode)
	if countryCode == "" {
		return authdomain.LoginIPRiskDecisionUnknown
	}
	if s.countryBlockedByStaticPolicy(countryCode) {
		return authdomain.LoginIPRiskDecisionBlocked
	}
	dynamicBlocked, err := s.countryBlockedByDynamicPolicy(ctx, countryCode)
	if err != nil {
		// 动态配置读取失败不能扩大误伤面；保留静态配置阻断，其余按 fail-open 放行并记录。
		logx.Warn(ctx, "login_risk_country_blocks_read_failed", slog.String("component", "user_auth_risk"), slog.String("country", countryCode), slog.String("error", err.Error()))
		return authdomain.LoginIPRiskDecisionAllowed
	}
	if dynamicBlocked {
		return authdomain.LoginIPRiskDecisionBlocked
	}
	return authdomain.LoginIPRiskDecisionAllowed
}

func (s *Service) ttlForDecision(decision string) time.Duration {
	switch decision {
	case authdomain.LoginIPRiskDecisionBlocked:
		return s.loginRiskPolicy.BlockedTTL
	case authdomain.LoginIPRiskDecisionAllowed:
		return s.loginRiskPolicy.AllowedTTL
	default:
		return s.loginRiskPolicy.UnknownTTL
	}
}

func (s *Service) applyLoginIPRiskDecision(ctx context.Context, job authdomain.LoginIPRiskJob, decision IPDecision, attempts []providerAttempt, status string) error {
	nowMs := s.now().UnixMilli()
	job.Status = status
	job.Decision = decision.Decision
	job.CountryCode = normalizeCountryCode(decision.Country)
	job.UpdatedAtMs = nowMs
	if decision.Decision == authdomain.LoginIPRiskDecisionUnknown {
		job.FailureReason = "provider_unavailable"
	}
	if err := s.authRepository.CompleteLoginIPRiskJob(ctx, job); err != nil {
		return err
	}
	logx.Info(ctx, "login_ip_risk_decision", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("decision", job.Decision), slog.String("country", job.CountryCode))
	if decision.Decision != authdomain.LoginIPRiskDecisionBlocked {
		return nil
	}
	// 风控任务异步执行时，客户端可能已经在任务完成前用 refresh token 轮换出新 session。
	// 因此不能只撤 job.session_id；必须沿源 session 的 user_id + device_id 撤销同一登录链路后续 active session，防止新 access token 继续通过 gateway。
	riskSessionIDs, err := s.authRepository.RevokeUserDeviceSessionsForRisk(ctx, job.SessionID, job.UserID, nowMs, revokeReasonGeoPolicyBlocked, job.RequestID, "risk_worker")
	if err != nil {
		return err
	}
	denylistSessionIDs := uniqueRiskSessionIDs(append([]string{job.SessionID}, riskSessionIDs...))
	if s.ipDecisionCache != nil {
		for _, sessionID := range denylistSessionIDs {
			// DB 吊销负责 refresh 续期失败，Redis denylist 负责让已经签发出去的 access token 立刻在 gateway 失效。
			if err := s.ipDecisionCache.SetRevokedSession(ctx, job.AppCode, sessionID, revokeReasonGeoPolicyBlocked, s.loginRiskPolicy.DenylistTTL); err != nil {
				logx.Warn(ctx, "revoked_session_denylist_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", sessionID), slog.String("error", err.Error()))
			}
		}
	}
	if err := s.authRepository.MarkLoginAuditBlocked(ctx, job.RequestID, job.UserID, job.CountryCode, riskBlockReasonIPAsync); err != nil {
		logx.Warn(ctx, "login_audit_mark_blocked_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("error", err.Error()))
	}
	logx.Info(ctx, "login_session_revoked_by_risk", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("reason", revokeReasonGeoPolicyBlocked), slog.String("country", job.CountryCode), slog.Int("risk_session_count", len(riskSessionIDs)), slog.Int("denylist_session_count", len(denylistSessionIDs)))
	_ = attempts
	return nil
}

func uniqueRiskSessionIDs(sessionIDs []string) []string {
	seen := make(map[string]struct{}, len(sessionIDs))
	unique := make([]string, 0, len(sessionIDs))
	for _, sessionID := range sessionIDs {
		sessionID = strings.TrimSpace(sessionID)
		if sessionID == "" {
			continue
		}
		if _, ok := seen[sessionID]; ok {
			continue
		}
		seen[sessionID] = struct{}{}
		unique = append(unique, sessionID)
	}
	return unique
}

func trimRiskFailure(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return ""
	}
	if len(value) > 128 {
		return value[:128]
	}
	return value
}

func (r IPGeoResult) String() string {
	return fmt.Sprintf("%s/%d", r.CountryCode, r.Confidence)
}
