package externaladmin

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

	"hyapp-admin-server/internal/appctx"
	"hyapp-admin-server/internal/cache"
	"hyapp-admin-server/internal/model"
	"hyapp-admin-server/internal/modules/shared"
	"hyapp-admin-server/internal/security"

	mysqlDriver "github.com/go-sql-driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

const (
	lastSeenWriteInterval  = 5 * time.Minute
	loginLimiterTimeout    = 200 * time.Millisecond
	loginLimiterClusterTag = "{external-login}"
)

type Service struct {
	db        *gorm.DB
	userDB    *sql.DB
	cfg       Config
	dummyHash string
	limiter   FixedWindowLimiter
}

func NewService(db *gorm.DB, userDB *sql.DB, cfg Config) *Service {
	if cfg.SessionTTL <= 0 {
		cfg.SessionTTL = 12 * time.Hour
	}
	if cfg.MaxLoginFailures == 0 {
		cfg.MaxLoginFailures = 5
	}
	if cfg.LockDuration <= 0 {
		cfg.LockDuration = 15 * time.Minute
	}
	if cfg.LoginRateLimit.Window <= 0 {
		cfg.LoginRateLimit.Window = 60 * time.Second
	}
	if cfg.LoginRateLimit.SystemLimit == 0 {
		cfg.LoginRateLimit.SystemLimit = 300
	}
	if cfg.LoginRateLimit.AppLimit == 0 {
		cfg.LoginRateLimit.AppLimit = 120
	}
	if cfg.LoginRateLimit.IPLimit == 0 {
		cfg.LoginRateLimit.IPLimit = 30
	}
	if cfg.LoginRateLimit.IdentityLimit == 0 {
		cfg.LoginRateLimit.IdentityLimit = 10
	}
	dummyHash, _ := security.HashPassword("external-login-dummy-password")
	return &Service{db: db, userDB: userDB, cfg: cfg, dummyHash: dummyHash}
}

func (s *Service) ResolveTarget(ctx context.Context, appCode string, target string) (LinkedUser, error) {
	appCode = normalizeAppCode(appCode)
	target = strings.TrimSpace(target)
	if appCode == "" || target == "" || s.userDB == nil {
		return LinkedUser{}, ErrInvalidInput
	}
	userID, matched, err := shared.ResolveExactUserID(ctx, s.userDB, appCode, target, time.Now().UTC().UnixMilli())
	if err != nil {
		return LinkedUser{}, err
	}
	if !matched {
		return LinkedUser{}, ErrTargetNotFound
	}
	users, err := s.loadLinkedUsers(ctx, appCode, []int64{userID})
	if err != nil {
		return LinkedUser{}, err
	}
	user, ok := users[userID]
	if !ok {
		return LinkedUser{}, ErrTargetNotFound
	}
	// ResolveExactUserID keeps nickname fallback for generic admin forms. External-account creation
	// accepts only an exact long/short/pretty ID so an ambiguous nickname can never bind credentials.
	if !matchesExactIdentity(user, target) {
		return LinkedUser{}, ErrTargetNotFound
	}
	return user, nil
}

func (s *Service) ListAccounts(ctx context.Context, input ListInput) (ListResult, error) {
	if s.db == nil {
		return ListResult{}, errors.New("admin mysql is not configured")
	}
	input.AppCode = normalizeAppCode(input.AppCode)
	input.Page, input.PageSize = normalizePage(input.Page, input.PageSize)
	query := s.db.WithContext(ctx).Model(&model.ExternalAdminAccount{}).Where("app_code = ?", input.AppCode)
	if status := normalizeStatus(input.Status); status != "" {
		query = query.Where("status = ?", status)
	}
	if keyword := strings.TrimSpace(input.Keyword); keyword != "" {
		like := "%" + keyword + "%"
		linkedUserID := int64(0)
		if user, resolveErr := s.ResolveTarget(ctx, input.AppCode, keyword); resolveErr == nil {
			linkedUserID = user.UserID
		} else if userID, parseErr := strconv.ParseInt(keyword, 10, 64); parseErr == nil && userID > 0 {
			linkedUserID = userID
		}
		if linkedUserID > 0 {
			query = query.Where("username LIKE ? OR linked_app_user_id = ?", like, linkedUserID)
		} else {
			query = query.Where("username LIKE ?", like)
		}
	}
	var total int64
	if err := query.Count(&total).Error; err != nil {
		return ListResult{}, err
	}
	accounts := []model.ExternalAdminAccount{}
	if err := query.Order("updated_at_ms DESC, id DESC").Offset((input.Page - 1) * input.PageSize).Limit(input.PageSize).Find(&accounts).Error; err != nil {
		return ListResult{}, err
	}
	userIDs := make([]int64, 0, len(accounts))
	for _, account := range accounts {
		userIDs = append(userIDs, account.LinkedAppUserID)
	}
	users, err := s.loadLinkedUsers(ctx, input.AppCode, userIDs)
	if err != nil {
		return ListResult{}, err
	}
	parentIDs := make([]uint64, 0, len(accounts))
	for _, account := range accounts {
		if account.ParentAccountID != nil {
			parentIDs = append(parentIDs, *account.ParentAccountID)
		}
	}
	parents := map[uint64]model.ExternalAdminAccount{}
	parentUsers := map[int64]LinkedUser{}
	if len(parentIDs) > 0 {
		parentRows := []model.ExternalAdminAccount{}
		if err := s.db.WithContext(ctx).Where("app_code = ? AND id IN ?", input.AppCode, parentIDs).Find(&parentRows).Error; err != nil {
			return ListResult{}, err
		}
		parentUserIDs := make([]int64, 0, len(parentRows))
		for _, parent := range parentRows {
			parents[parent.ID] = parent
			parentUserIDs = append(parentUserIDs, parent.LinkedAppUserID)
		}
		parentUsers, err = s.loadLinkedUsers(ctx, input.AppCode, parentUserIDs)
		if err != nil {
			return ListResult{}, err
		}
	}
	items := make([]AccountDTO, 0, len(accounts))
	for _, account := range accounts {
		var parent *model.ExternalAdminAccount
		if account.ParentAccountID != nil {
			if resolved, ok := parents[*account.ParentAccountID]; ok {
				parent = &resolved
			}
		}
		items = append(items, accountDTOWithParent(account, users[account.LinkedAppUserID], parent, parentUsers))
	}
	return ListResult{Items: items, Page: input.Page, PageSize: input.PageSize, Total: total}, nil
}

func (s *Service) FindAccountByLinkedUser(ctx context.Context, appCode string, linkedUser LinkedUser) (*AccountDTO, error) {
	if s.db == nil || linkedUser.UserID <= 0 {
		return nil, ErrInvalidInput
	}
	var account model.ExternalAdminAccount
	err := s.db.WithContext(ctx).Where("app_code = ? AND linked_app_user_id = ?", normalizeAppCode(appCode), linkedUser.UserID).First(&account).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	dto, err := s.accountDTOWithRelations(ctx, account)
	if err != nil {
		return nil, err
	}
	return &dto, nil
}

func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (AccountDTO, error) {
	if s.db == nil {
		return AccountDTO{}, errors.New("admin mysql is not configured")
	}
	input.AppCode = normalizeAppCode(input.AppCode)
	input.Username = normalizeUsername(input.Username)
	input.IdentityType = normalizeIdentity(input.IdentityType)
	if input.AppCode == "" || !validUsername(input.Username) || input.IdentityType == "" || input.CreatedByAdminID == 0 {
		return AccountDTO{}, ErrInvalidInput
	}
	if err := validatePassword(input.Password); err != nil {
		return AccountDTO{}, err
	}
	permissions, err := createPermissionSnapshot(input)
	if err != nil {
		return AccountDTO{}, err
	}
	// Login has no client-selected App. Check the normalized username globally before
	// doing an owner-DB lookup or bcrypt work; the global unique index remains the
	// authoritative race-safe guard if two administrators create it concurrently.
	var existing model.ExternalAdminAccount
	err = s.db.WithContext(ctx).Select("id").Where("username = ?", input.Username).Take(&existing).Error
	if err == nil {
		return AccountDTO{}, ErrAccountConflict
	}
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		return AccountDTO{}, err
	}
	if _, err := s.findActiveApp(ctx, input.AppCode); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return AccountDTO{}, ErrInvalidInput
		}
		return AccountDTO{}, err
	}
	linkedUser, err := s.ResolveTarget(ctx, input.AppCode, input.TargetUserID)
	if err != nil {
		return AccountDTO{}, err
	}
	linkedRegionID, err := s.activeUserRegion(ctx, input.AppCode, linkedUser.UserID)
	if err != nil {
		return AccountDTO{}, err
	}
	if linkedRegionID <= 0 {
		return AccountDTO{}, ErrInvalidHierarchy
	}
	passwordHash, err := security.HashPasswordWithoutMinimum(input.Password)
	if err != nil {
		return AccountDTO{}, ErrInvalidInput
	}
	permissionsJSON, err := encodePermissions(permissions)
	if err != nil {
		return AccountDTO{}, err
	}
	account := model.ExternalAdminAccount{
		AppCode:            input.AppCode,
		LinkedAppUserID:    linkedUser.UserID,
		Username:           input.Username,
		PasswordHash:       passwordHash,
		PermissionsJSON:    permissionsJSON,
		PermissionRevision: 1,
		IdentityType:       input.IdentityType,
		ParentAccountID:    input.ParentAccountID,
		Status:             model.ExternalAdminStatusActive,
		CreatedByAdminID:   input.CreatedByAdminID,
	}
	// The legacy model/database default is true, so assigning Go's false zero value
	// is not enough: GORM substitutes the default during Create. Clear the marker in
	// the same transaction so a rolling-release request handled by an older instance
	// cannot revive the retired first-login gate for a newly created account.
	err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		// Parent validation is repeated under an admin-row share lock so a concurrent
		// disable/rebind cannot race this credential into an invalid portal hierarchy.
		if _, err := s.validateParentForChild(ctx, tx, input.AppCode, input.IdentityType, input.ParentAccountID, linkedRegionID); err != nil {
			return err
		}
		if err := tx.Create(&account).Error; err != nil {
			return err
		}
		if err := tx.Model(&account).UpdateColumn("password_change_required", false).Error; err != nil {
			return err
		}
		account.PasswordChangeRequired = false
		return nil
	})
	if err != nil {
		if isDuplicateKey(err) {
			return AccountDTO{}, ErrAccountConflict
		}
		return AccountDTO{}, err
	}
	return s.accountDTOWithRelations(ctx, account)
}

func (s *Service) SetStatus(ctx context.Context, appCode string, id uint64, status string) (AccountDTO, error) {
	appCode = normalizeAppCode(appCode)
	status = normalizeStatus(status)
	if id == 0 || status == "" {
		return AccountDTO{}, ErrInvalidInput
	}
	var account model.ExternalAdminAccount
	err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
			return err
		}
		updates := map[string]any{"status": status}
		if status == model.ExternalAdminStatusActive {
			updates["failed_login_count"] = 0
			updates["locked_until_ms"] = 0
		}
		if err := tx.Model(&account).Updates(updates).Error; err != nil {
			return err
		}
		if status == model.ExternalAdminStatusDisabled {
			return revokeAllSessions(tx, account.ID, "account_disabled", time.Now().UTC().UnixMilli())
		}
		return nil
	})
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return AccountDTO{}, ErrAccountNotFound
	}
	if err != nil {
		return AccountDTO{}, err
	}
	if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil {
		return AccountDTO{}, err
	}
	return s.accountDTOWithRelations(ctx, account)
}

func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64, password string) (AccountDTO, error) {
	appCode = normalizeAppCode(appCode)
	if id == 0 {
		return AccountDTO{}, ErrInvalidInput
	}
	if err := validatePassword(password); err != nil {
		return AccountDTO{}, err
	}
	hash, err := security.HashPasswordWithoutMinimum(password)
	if err != nil {
		return AccountDTO{}, ErrInvalidInput
	}
	var account model.ExternalAdminAccount
	err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
			return err
		}
		if err := tx.Model(&account).Updates(map[string]any{
			"password_hash": hash,
			// A reset still revokes every active session, but the replacement password
			// is immediately usable and never creates a first-login rotation gate.
			"password_change_required": false,
			"failed_login_count":       0,
			"locked_until_ms":          0,
		}).Error; err != nil {
			return err
		}
		return revokeAllSessions(tx, account.ID, "password_reset", time.Now().UTC().UnixMilli())
	})
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return AccountDTO{}, ErrAccountNotFound
	}
	if err != nil {
		return AccountDTO{}, err
	}
	if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil {
		return AccountDTO{}, err
	}
	return s.accountDTOWithRelations(ctx, account)
}

func (s *Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
	input.Username = normalizeUsername(input.Username)
	input.IP = truncateText(strings.TrimSpace(input.IP), 64)
	input.UserAgent = truncateText(strings.TrimSpace(input.UserAgent), 512)
	// The pre-resolution limiter uses only facts the server can trust: system, real
	// client IP and the globally normalized username. AppCode is intentionally absent,
	// so a forged tenant cannot select another account or evade any of these counters.
	if err := s.consumeUnresolvedLoginRateLimit(ctx, input); err != nil {
		return LoginResult{}, err
	}
	if s.db == nil {
		return LoginResult{}, errors.New("admin mysql is not configured")
	}
	if !validUsername(input.Username) || input.Password == "" {
		security.CheckPassword(s.dummyHash, input.Password)
		return LoginResult{}, ErrInvalidCredentials
	}

	// The global unique index makes this indexed projection the sole tenant resolver.
	// Do not accept a request/header App here: only the AppCode stored with the account
	// may select owner data, create the session or populate audit rows.
	var resolvedAccount model.ExternalAdminAccount
	err := s.db.WithContext(ctx).Select("id", "app_code").Where("username = ?", input.Username).Take(&resolvedAccount).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		security.CheckPassword(s.dummyHash, input.Password)
		s.writeUnknownLoginLog(ctx, input, "invalid_credentials")
		return LoginResult{}, ErrInvalidCredentials
	}
	if err != nil {
		return LoginResult{}, err
	}
	resolvedAccount.AppCode = normalizeAppCode(resolvedAccount.AppCode)
	if !validAppCode(resolvedAccount.AppCode) {
		security.CheckPassword(s.dummyHash, input.Password)
		s.writeResolvedLoginLog(ctx, resolvedAccount.ID, resolvedAccount.AppCode, input, "invalid_account_app")
		return LoginResult{}, ErrInvalidCredentials
	}
	// App quota is consumed in a second Redis call only after the indexed account
	// resolution. System/IP/username rules are not repeated, so one attempt increments
	// each layer exactly once while still containing a targeted tenant attack.
	if err := s.consumeResolvedAppLoginRateLimit(ctx, resolvedAccount.AppCode); err != nil {
		return LoginResult{}, err
	}
	app, err := s.findActiveApp(ctx, resolvedAccount.AppCode)
	if err != nil {
		// Keep disabled App, unknown account and wrong password indistinguishable.
		security.CheckPassword(s.dummyHash, input.Password)
		if errors.Is(err, sql.ErrNoRows) {
			s.writeResolvedLoginLog(ctx, resolvedAccount.ID, resolvedAccount.AppCode, input, "app_inactive")
			return LoginResult{}, ErrInvalidCredentials
		}
		return LoginResult{}, err
	}

	nowMS := time.Now().UTC().UnixMilli()
	var account model.ExternalAdminAccount
	var session model.ExternalAdminSession
	var sessionToken string
	var csrfToken string
	var authErr error
	// Lock the account row while checking/updating the failure counter. Concurrent bad passwords
	// must serialize on one counter; otherwise attackers could race requests below the lock threshold.
	err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND username = ?", resolvedAccount.ID, input.Username).First(&account).Error
		if errors.Is(err, gorm.ErrRecordNotFound) {
			security.CheckPassword(s.dummyHash, input.Password)
			authErr = ErrInvalidCredentials
			return tx.Create(&model.ExternalAdminLoginLog{
				AppCode: resolvedAccount.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent,
				Status: "failed", Message: "invalid_credentials",
			}).Error
		}
		if err != nil {
			return err
		}

		accountID := account.ID
		validPassword := security.CheckPassword(account.PasswordHash, input.Password)
		accountAppCode := normalizeAppCode(account.AppCode)
		if accountAppCode != resolvedAccount.AppCode {
			// AppCode is not mutable through this module, but a concurrent/manual data
			// correction between projection and row lock must not create a session whose
			// App metadata and tenant scope came from different rows/snapshots.
			authErr = ErrInvalidCredentials
			return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: accountAppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_app_changed"}).Error
		}
		account.AppCode = accountAppCode
		if account.Status != model.ExternalAdminStatusActive {
			authErr = ErrInvalidCredentials
			return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_disabled"}).Error
		}
		if account.LockedUntilMS > nowMS {
			authErr = ErrInvalidCredentials
			return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_locked"}).Error
		}
		if !validPassword {
			failedCount := account.FailedLoginCount
			if account.LockedUntilMS > 0 && account.LockedUntilMS <= nowMS {
				failedCount = 0
			}
			failedCount++
			lockedUntilMS := int64(0)
			message := "invalid_credentials"
			if failedCount >= s.cfg.MaxLoginFailures {
				lockedUntilMS = time.Now().UTC().Add(s.cfg.LockDuration).UnixMilli()
				failedCount = 0
				message = "failure_limit_locked"
			}
			if err := tx.Model(&account).Updates(map[string]any{"failed_login_count": failedCount, "locked_until_ms": lockedUntilMS}).Error; err != nil {
				return err
			}
			authErr = ErrInvalidCredentials
			return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: message}).Error
		}

		var tokenHash string
		sessionToken, tokenHash, err = security.NewRefreshToken()
		if err != nil {
			return err
		}
		var csrfHash string
		csrfToken, csrfHash, err = security.NewRefreshToken()
		if err != nil {
			return err
		}
		session = model.ExternalAdminSession{
			AccountID: account.ID, AppCode: account.AppCode, TokenHash: tokenHash, CSRFTokenHash: csrfHash,
			IP: input.IP, UserAgent: input.UserAgent, ExpiresAtMS: time.Now().UTC().Add(s.cfg.SessionTTL).UnixMilli(), LastSeenAtMS: nowMS,
		}
		if err := tx.Create(&session).Error; err != nil {
			return err
		}
		if err := tx.Model(&account).Updates(map[string]any{"failed_login_count": 0, "locked_until_ms": 0, "last_login_at_ms": nowMS}).Error; err != nil {
			return err
		}
		account.LastLoginAtMS = &nowMS
		return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "success", Message: "login_success"}).Error
	})
	if err != nil {
		return LoginResult{}, err
	}
	if authErr != nil {
		return LoginResult{}, authErr
	}
	view, err := s.sessionView(ctx, account, app, csrfToken)
	if err != nil {
		_ = s.RevokeSession(ctx, session.ID, "profile_load_failed")
		return LoginResult{}, err
	}
	return LoginResult{SessionToken: sessionToken, CSRFToken: csrfToken, View: view}, nil
}

func (s *Service) consumeUnresolvedLoginRateLimit(ctx context.Context, input LoginInput) error {
	if s.limiter == nil {
		return ErrLoginLimiterFailed
	}
	ipDigest := loginRateLimitDigest(input.IP)
	identityDigest := loginRateLimitDigest(input.Username)
	rules := []cache.FixedWindowRule{
		// These keys are independent of App because no client-provided tenant value
		// is trusted before the globally unique username resolves an account.
		{Key: "rate:" + loginLimiterClusterTag + ":system", Limit: s.cfg.LoginRateLimit.SystemLimit},
		{Key: "rate:" + loginLimiterClusterTag + ":ip:" + ipDigest, Limit: s.cfg.LoginRateLimit.IPLimit},
		{Key: "rate:" + loginLimiterClusterTag + ":identity:" + identityDigest, Limit: s.cfg.LoginRateLimit.IdentityLimit},
	}
	return s.consumeLoginRateLimitRules(ctx, rules)
}

func (s *Service) consumeResolvedAppLoginRateLimit(ctx context.Context, appCode string) error {
	if s.limiter == nil {
		return ErrLoginLimiterFailed
	}
	rules := []cache.FixedWindowRule{{
		Key:   "rate:" + loginLimiterClusterTag + ":app:" + loginRateLimitDigest(normalizeAppCode(appCode)),
		Limit: s.cfg.LoginRateLimit.AppLimit,
	}}
	return s.consumeLoginRateLimitRules(ctx, rules)
}

func (s *Service) consumeLoginRateLimitRules(ctx context.Context, rules []cache.FixedWindowRule) error {
	limitCtx, cancel := context.WithTimeout(ctx, loginLimiterTimeout)
	defer cancel()
	decision, err := s.limiter.ConsumeFixedWindow(limitCtx, s.cfg.LoginRateLimit.Window, rules)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrLoginLimiterFailed, err)
	}
	if !decision.Allowed {
		retryAfter := decision.RetryAfter
		if retryAfter <= 0 {
			retryAfter = s.cfg.LoginRateLimit.Window
		}
		return &LoginRateLimitError{RetryAfter: retryAfter}
	}
	return nil
}

func loginRateLimitDigest(value string) string {
	digest := sha256.Sum256([]byte(value))
	// 128 bits of the SHA-256 output keeps keys compact while retaining ample
	// collision resistance; no App, IP or account identifier appears in Redis.
	return hex.EncodeToString(digest[:16])
}

func (s *Service) Authenticate(ctx context.Context, rawToken string) (SessionPrincipal, error) {
	if strings.TrimSpace(rawToken) == "" || s.db == nil {
		return SessionPrincipal{}, ErrInvalidSession
	}
	nowMS := time.Now().UTC().UnixMilli()
	var session model.ExternalAdminSession
	err := s.db.WithContext(ctx).Preload("Account").Where("token_hash = ? AND revoked_at_ms = 0 AND expires_at_ms > ?", security.HashToken(rawToken), nowMS).First(&session).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return SessionPrincipal{}, ErrInvalidSession
	}
	if err != nil {
		return SessionPrincipal{}, err
	}
	if session.Account.Status != model.ExternalAdminStatusActive || session.Account.AppCode != session.AppCode {
		_ = s.RevokeSession(ctx, session.ID, "account_invalid")
		return SessionPrincipal{}, ErrInvalidSession
	}
	// Login-time App validation is insufficient because an already authenticated
	// browser can skip /auth/me and call a business route directly after operators
	// disable the App. Re-read the indexed App row on every opaque-session auth.
	// Only a confirmed missing/inactive App revokes the session; transient user DB
	// failures fail closed for this request without destroying a recoverable session.
	if _, err := s.findActiveApp(ctx, session.AppCode); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			_ = s.RevokeSession(ctx, session.ID, "app_disabled")
			return SessionPrincipal{}, ErrInvalidSession
		}
		return SessionPrincipal{}, err
	}
	permissions, err := decodePermissions(session.Account.PermissionsJSON)
	if err != nil {
		_ = s.RevokeSession(ctx, session.ID, "permission_snapshot_invalid")
		return SessionPrincipal{}, ErrInvalidSession
	}
	scope, err := s.resolvePortalScope(ctx, session.Account)
	if err != nil {
		return SessionPrincipal{}, err
	}
	if nowMS-session.LastSeenAtMS >= lastSeenWriteInterval.Milliseconds() {
		// last_seen is intentionally throttled so read-heavy external pages do not turn every request into a write.
		_ = s.db.WithContext(ctx).Model(&model.ExternalAdminSession{}).Where("id = ? AND revoked_at_ms = 0", session.ID).Update("last_seen_at_ms", nowMS).Error
	}
	operatorID, err := operatorIDForAccount(session.Account.ID)
	if err != nil {
		_ = s.RevokeSession(ctx, session.ID, "account_id_out_of_range")
		return SessionPrincipal{}, ErrInvalidSession
	}
	return SessionPrincipal{
		SessionID: session.ID, AccountID: session.Account.ID, OperatorID: operatorID, AppCode: session.AppCode,
		LinkedAppUserID: session.Account.LinkedAppUserID, Username: session.Account.Username,
		IdentityType: normalizeIdentity(session.Account.IdentityType), RegionID: scope.RegionID,
		ScopeOwnerUserIDs: append([]int64(nil), scope.OwnerUserIDs...),
		// The persisted flag is a deprecated compatibility column. Always expose false
		// so historical rows marked by older releases cannot recreate the retired gate.
		Permissions: permissions, PasswordChangeRequired: false,
		CSRFTokenHash: session.CSRFTokenHash, ExpiresAtMS: session.ExpiresAtMS,
	}, nil
}

func (s *Service) Me(ctx context.Context, principal SessionPrincipal, csrfToken string) (SessionView, error) {
	var account model.ExternalAdminAccount
	if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).First(&account).Error; err != nil {
		return SessionView{}, err
	}
	app, err := s.findActiveApp(ctx, principal.AppCode)
	if err != nil {
		return SessionView{}, err
	}
	return s.sessionView(ctx, account, app, csrfToken)
}

func (s *Service) ChangePassword(ctx context.Context, principal SessionPrincipal, input ChangePasswordInput) error {
	if err := validatePassword(input.NewPassword); err != nil {
		return err
	}
	if strings.TrimSpace(input.CurrentPassword) == "" {
		return ErrInvalidInput
	}
	hash, err := security.HashPasswordWithoutMinimum(input.NewPassword)
	if err != nil {
		return ErrInvalidInput
	}
	var passwordErr error
	err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		var account model.ExternalAdminAccount
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).First(&account).Error; err != nil {
			return err
		}
		if !security.CheckPassword(account.PasswordHash, input.CurrentPassword) {
			passwordErr = ErrInvalidCredentials
			return nil
		}
		// This comparison must use the hash read under the account row lock. Otherwise
		// two concurrent password changes could both compare against a stale hash.
		// Returning before both Updates calls also guarantees a rejected request does
		// not revoke the account's other active sessions.
		if security.CheckPassword(account.PasswordHash, input.NewPassword) {
			passwordErr = ErrPasswordReused
			return nil
		}
		if err := tx.Model(&account).Updates(map[string]any{
			"password_hash": hash, "password_change_required": false,
			"failed_login_count": 0, "locked_until_ms": 0,
		}).Error; err != nil {
			return err
		}
		nowMS := time.Now().UTC().UnixMilli()
		return tx.Model(&model.ExternalAdminSession{}).
			Where("account_id = ? AND id <> ? AND revoked_at_ms = 0", principal.AccountID, principal.SessionID).
			Updates(map[string]any{"revoked_at_ms": nowMS, "revoke_reason": "password_changed"}).Error
	})
	if err != nil {
		return err
	}
	return passwordErr
}

func (s *Service) RevokeSession(ctx context.Context, sessionID uint64, reason string) error {
	if sessionID == 0 || s.db == nil {
		return nil
	}
	return s.db.WithContext(ctx).Model(&model.ExternalAdminSession{}).
		Where("id = ? AND revoked_at_ms = 0", sessionID).
		Updates(map[string]any{"revoked_at_ms": time.Now().UTC().UnixMilli(), "revoke_reason": strings.TrimSpace(reason)}).Error
}

func (s *Service) CreateOperationLog(ctx context.Context, log model.ExternalAdminOperationLog) error {
	if s.db == nil {
		return errors.New("admin mysql is not configured")
	}
	return s.db.WithContext(ctx).Create(&log).Error
}

func (s *Service) sessionView(ctx context.Context, account model.ExternalAdminAccount, app App, csrfToken string) (SessionView, error) {
	users, err := s.loadLinkedUsers(ctx, account.AppCode, []int64{account.LinkedAppUserID})
	if err != nil {
		return SessionView{}, err
	}
	user, ok := users[account.LinkedAppUserID]
	if !ok {
		return SessionView{}, ErrTargetNotFound
	}
	permissions, err := decodePermissions(account.PermissionsJSON)
	if err != nil {
		return SessionView{}, err
	}
	scope, err := s.resolvePortalScope(ctx, account)
	if err != nil {
		return SessionView{}, err
	}
	identity := normalizeIdentity(account.IdentityType)
	return SessionView{
		User: user,
		Account: AccountSummary{
			ID: account.ID, Username: account.Username, Status: account.Status,
			IdentityType: identity, ParentAccountID: account.ParentAccountID,
		},
		App: app, AppCode: account.AppCode, Permissions: permissions, Capabilities: capabilitiesFor(permissions),
		IdentityType: identity, RegionID: scope.RegionID,
		ExternalSuperAdminCount: int64(len(scope.ExternalSuperAdminAccounts)),
		// Keep the wire field during rolling upgrades, but never surface the deprecated
		// database marker as an instruction to block navigation or business APIs.
		PasswordChangeRequired: false, CSRFToken: csrfToken,
	}, nil
}

func (s *Service) findActiveApp(ctx context.Context, appCode string) (App, error) {
	if s.userDB == nil {
		return App{}, errors.New("user mysql is not configured")
	}
	var app App
	err := s.userDB.QueryRowContext(ctx, `SELECT app_code, app_name, logo_url FROM apps WHERE app_code = ? AND status = 'active' LIMIT 1`, normalizeAppCode(appCode)).Scan(&app.AppCode, &app.AppName, &app.LogoURL)
	return app, err
}

func (s *Service) loadLinkedUsers(ctx context.Context, appCode string, userIDs []int64) (map[int64]LinkedUser, error) {
	users := make(map[int64]LinkedUser, len(userIDs))
	if len(userIDs) == 0 {
		return users, nil
	}
	if s.userDB == nil {
		return nil, errors.New("user mysql is not configured")
	}
	uniqueIDs := make([]int64, 0, len(userIDs))
	seen := map[int64]struct{}{}
	for _, userID := range userIDs {
		if userID <= 0 {
			continue
		}
		if _, ok := seen[userID]; ok {
			continue
		}
		seen[userID] = struct{}{}
		uniqueIDs = append(uniqueIDs, userID)
	}
	if len(uniqueIDs) == 0 {
		return users, nil
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",")
	nowMS := time.Now().UTC().UnixMilli()
	args := []any{nowMS, nowMS, normalizeAppCode(appCode)}
	for _, userID := range uniqueIDs {
		args = append(args, userID)
	}
	rows, err := s.userDB.QueryContext(ctx, `
		SELECT u.user_id,
		       COALESCE(u.current_display_user_id, ''),
		       COALESCE(u.default_display_user_id, ''),
		       COALESCE((
		         SELECT lease.display_user_id
		         FROM pretty_display_user_id_leases lease
		         WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id
		           AND lease.status = 'active'
		           AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
		         ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1
		       ), ''),
		       COALESCE((
		         SELECT pdi.pretty_id
		         FROM pretty_display_user_id_leases lease
		         JOIN pretty_display_ids pdi ON pdi.app_code = lease.app_code
		           AND pdi.assigned_lease_id = lease.lease_id AND pdi.status = 'assigned'
		         WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id
		           AND lease.status = 'active'
		           AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
		         ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1
		       ), ''),
		       COALESCE(u.username, ''), COALESCE(u.avatar, ''), COALESCE(u.status, '')
		FROM users u
		WHERE u.app_code = ? AND u.user_id IN (`+placeholders+`)`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var user LinkedUser
		if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.DefaultDisplayUserID, &user.PrettyDisplayUserID, &user.PrettyID, &user.Username, &user.Avatar, &user.Status); err != nil {
			return nil, err
		}
		users[user.UserID] = user
	}
	return users, rows.Err()
}

func (s *Service) writeUnknownLoginLog(ctx context.Context, input LoginInput, message string) {
	if s.db == nil {
		return
	}
	// Empty app_code is the audit sentinel for an unresolved username. Persisting a
	// request-supplied tenant here would incorrectly attribute an attack to a real App.
	_ = s.db.WithContext(ctx).Create(&model.ExternalAdminLoginLog{
		AppCode: "", Username: input.Username, IP: input.IP, UserAgent: input.UserAgent,
		Status: "failed", Message: message,
	}).Error
}

func (s *Service) writeResolvedLoginLog(ctx context.Context, accountID uint64, appCode string, input LoginInput, message string) {
	if s.db == nil {
		return
	}
	_ = s.db.WithContext(ctx).Create(&model.ExternalAdminLoginLog{
		AccountID: &accountID, AppCode: normalizeAppCode(appCode), Username: input.Username,
		IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: message,
	}).Error
}

func accountDTO(account model.ExternalAdminAccount, linkedUser LinkedUser) AccountDTO {
	permissions, _ := decodePermissions(account.PermissionsJSON)
	if linkedUser.UserID == 0 {
		linkedUser.UserID = account.LinkedAppUserID
	}
	return AccountDTO{
		ID: account.ID, AppCode: account.AppCode, Username: account.Username, Status: account.Status,
		LinkedUser: linkedUser, Permissions: permissions, PermissionRevision: account.PermissionRevision,
		IdentityType: normalizeIdentity(account.IdentityType), ParentAccountID: account.ParentAccountID,
		CreatedByAdminID: account.CreatedByAdminID,
		CreatedAtMS:      account.CreatedAtMS, UpdatedAtMS: account.UpdatedAtMS, LastLoginAtMS: account.LastLoginAtMS,
	}
}

func revokeAllSessions(tx *gorm.DB, accountID uint64, reason string, nowMS int64) error {
	return tx.Model(&model.ExternalAdminSession{}).Where("account_id = ? AND revoked_at_ms = 0", accountID).
		Updates(map[string]any{"revoked_at_ms": nowMS, "revoke_reason": reason}).Error
}

func matchesExactIdentity(user LinkedUser, target string) bool {
	target = strings.TrimSpace(target)
	return target == strconv.FormatInt(user.UserID, 10) ||
		target == user.DisplayUserID || target == user.DefaultDisplayUserID || target == user.PrettyDisplayUserID || target == user.PrettyID
}

func normalizePage(page int, pageSize int) (int, int) {
	if page < 1 {
		page = 1
	}
	if pageSize < 1 {
		pageSize = 20
	}
	if pageSize > 100 {
		pageSize = 100
	}
	return page, pageSize
}

func normalizeAppCode(value string) string {
	value = strings.ToLower(strings.TrimSpace(value))
	if value == "" {
		return ""
	}
	return appctx.Normalize(value)
}

func normalizeUsername(value string) string {
	return strings.ToLower(strings.TrimSpace(value))
}

func normalizeStatus(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case model.ExternalAdminStatusActive:
		return model.ExternalAdminStatusActive
	case model.ExternalAdminStatusDisabled:
		return model.ExternalAdminStatusDisabled
	default:
		return ""
	}
}

func validUsername(value string) bool {
	if len(value) < 3 || len(value) > 64 {
		return false
	}
	for _, char := range value {
		if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '_' || char == '-' {
			continue
		}
		return false
	}
	return true
}

func validAppCode(value string) bool {
	if len(value) == 0 || len(value) > 32 {
		return false
	}
	for _, char := range value {
		if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' || char == '-' {
			continue
		}
		return false
	}
	return true
}

func truncateText(value string, maxRunes int) string {
	if maxRunes <= 0 {
		return ""
	}
	runes := []rune(value)
	if len(runes) <= maxRunes {
		return value
	}
	return string(runes[:maxRunes])
}

func validatePassword(password string) error {
	// External administrators intentionally have no minimum password length. Preserve
	// exact bytes (no trim), reject unusable all-whitespace credentials, and enforce
	// bcrypt's hard 72-byte input limit before doing the expensive hash operation.
	if strings.TrimSpace(password) == "" {
		return ErrPasswordBlank
	}
	if len(password) > 72 {
		return fmt.Errorf("%w: password length must not exceed 72 bytes", ErrInvalidInput)
	}
	return nil
}

func isDuplicateKey(err error) bool {
	var mysqlErr *mysqlDriver.MySQLError
	return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}
