class User < ApplicationRecord
  # Encrypt TOTP secrets at rest (key derived from SECRET_KEY_BASE)
  encrypts :totp_secret

  has_secure_password
  has_many :sessions, dependent: :destroy
  has_many :user_groups, dependent: :destroy
  has_many :groups, through: :user_groups
  has_many :application_user_claims, dependent: :destroy
  has_many :oidc_user_consents, dependent: :destroy
  has_many :webauthn_credentials, dependent: :destroy
  has_many :api_keys, dependent: :destroy

  # Token generation for passwordless flows
  generates_token_for :invitation_login, expires_in: 24.hours do
    updated_at
  end

  generates_token_for :password_reset, expires_in: 1.hour do
    updated_at
  end

  normalizes :email_address, with: ->(e) { e.strip.downcase }
  normalizes :username, with: ->(u) { u.strip.downcase if u.present? }

  # Reserved OIDC claim names that should not be overridden
  RESERVED_CLAIMS = %w[
    iss sub aud exp iat nbf jti nonce azp
    email email_verified preferred_username name
    groups
  ].freeze

  validates :email_address, presence: true, uniqueness: {case_sensitive: false},
    format: {with: URI::MailTo::EMAIL_REGEXP}
  validates :username, uniqueness: {case_sensitive: false}, allow_nil: true,
    format: {with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens"},
    length: {minimum: 2, maximum: 30}
  validates :password, length: {minimum: 8}, allow_nil: true
  validate :no_reserved_claim_names

  # Enum - automatically creates scopes (User.active, User.disabled, etc.)
  enum :status, {active: 0, disabled: 1, pending_invitation: 2}

  # When an account stops being active (e.g. an admin disables it), immediately
  # terminate its sessions so access is revoked everywhere, not just on expiry.
  # Defence-in-depth: session lookup also filters by active status at request time.
  after_update_commit :revoke_sessions_when_deactivated

  # Scopes
  scope :admins, -> { joins(:groups).where(groups: {admin: true}).distinct }

  # Set true on a user (or on the user_params) to skip the auto-assign callback
  # for that record. Used by the admin invite form (opt-out checkbox) and by
  # tests that want a clean slate.
  attr_accessor :skip_auto_assign

  after_create :add_to_auto_assign_groups, unless: :skip_auto_assign

  def admin?
    groups.any?(&:admin?)
  end

  # TOTP methods
  def totp_enabled?
    totp_secret.present?
  end

  def enable_totp!
    require "rotp"
    self.totp_secret = ROTP::Base32.random
    # generate_backup_codes assigns the BCrypt hashes to self.backup_codes and
    # returns the plaintext codes for display. Do NOT reassign backup_codes to the
    # return value — that would store the plaintext codes and break verification.
    generate_backup_codes
    save!
  end

  def disable_totp!
    # Note: This does NOT clear totp_required flag
    # Admins control that flag via admin panel, users cannot remove admin-required 2FA
    update!(totp_secret: nil, backup_codes: nil)
  end

  def totp_provisioning_uri(issuer: "Clinch")
    return nil unless totp_enabled?

    require "rotp"
    totp = ROTP::TOTP.new(totp_secret, issuer: issuer)
    totp.provisioning_uri(email_address)
  end

  def verify_totp(code)
    return false unless totp_enabled?

    require "rotp"
    totp = ROTP::TOTP.new(totp_secret)
    # Pass `after:` so a code can only be accepted once: ROTP rejects any timestep
    # at or before the last accepted one, closing the ~90s drift-window replay.
    verified_at = totp.verify(code, drift_behind: 30, drift_ahead: 30, after: last_otp_at)
    return false unless verified_at

    update_column(:last_otp_at, verified_at)
    true
  end

  # Console/debug helper: get current TOTP code
  def console_totp
    return nil unless totp_enabled?

    require "rotp"
    ROTP::TOTP.new(totp_secret).now
  end

  def verify_backup_code(code)
    return false unless backup_codes.present?

    # Rate limiting: prevent brute force attacks on backup codes
    if rate_limit_backup_code_verification?
      Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}"
      return false
    end

    # backup_codes is now an Array (JSON column), no need to parse
    # Find the matching hash by comparing with BCrypt
    matching_hash = backup_codes.find do |hashed_code|
      BCrypt::Password.new(hashed_code) == code
    end

    if matching_hash
      # Remove the used hash from the array (single-use property)
      backup_codes.delete(matching_hash)
      save! # Save the updated array

      # Log successful backup code usage for security monitoring
      Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.ip_address}"
      true
    else
      # Increment failed attempt counter and log for security monitoring
      increment_backup_code_failed_attempts
      Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.ip_address}"
      false
    end
  end

  # Rate limiting for backup code verification to prevent brute force attacks
  def rate_limit_backup_code_verification?
    # Use Rails.cache to track failed attempts
    cache_key = "backup_code_failed_attempts_#{id}"
    attempts = Rails.cache.read(cache_key) || 0

    attempts >= 5
  end

  # Increment failed attempt counter
  def increment_backup_code_failed_attempts
    cache_key = "backup_code_failed_attempts_#{id}"
    attempts = Rails.cache.read(cache_key) || 0
    Rails.cache.write(cache_key, attempts + 1, expires_in: 1.hour)
  end

  # WebAuthn methods
  def webauthn_enabled?
    webauthn_credentials.exists?
  end

  def can_authenticate_with_webauthn?
    webauthn_enabled? && active?
  end

  def require_webauthn?
    webauthn_required? || (webauthn_enabled? && !password_digest.present?)
  end

  # Generate stable WebAuthn user handle on first use
  def webauthn_user_handle
    return webauthn_id if webauthn_id.present?

    # Generate random 64-byte opaque identifier (base64url encoded)
    handle = SecureRandom.urlsafe_base64(64)
    update_column(:webauthn_id, handle)
    handle
  end

  def platform_authenticators
    webauthn_credentials.platform_authenticators
  end

  def roaming_authenticators
    webauthn_credentials.roaming_authenticators
  end

  def webauthn_credential_for(external_id)
    webauthn_credentials.find_by(external_id: external_id)
  end

  # Check if user has any backed up (synced) passkeys
  def has_synced_passkeys?
    webauthn_credentials.exists?(backup_eligible: true, backup_state: true)
  end

  # Preferred authentication method for login flow
  def preferred_authentication_method
    return :webauthn if require_webauthn?
    return :webauthn if can_authenticate_with_webauthn? && preferred_2fa_method == "webauthn"
    return :password if password_digest.present?
    :webauthn
  end

  def has_oidc_consent?(application, requested_scopes)
    oidc_user_consents
      .where(application: application)
      .find { |consent| consent.covers_scopes?(requested_scopes) }
  end

  def revoke_consent!(application)
    consent = oidc_user_consents.find_by(application: application)
    consent&.destroy
  end

  def revoke_all_consents!
    oidc_user_consents.destroy_all
  end

  # Parse custom_claims JSON field
  def parsed_custom_claims
    return {} if custom_claims.blank?
    custom_claims.is_a?(Hash) ? custom_claims : {}
  end

  # Get fully merged claims for a specific application
  def merged_claims_for_application(application)
    merged = {}

    # Start with group claims (in order)
    groups.each do |group|
      merged.merge!(group.parsed_custom_claims)
    end

    # Merge user global claims
    merged.merge!(parsed_custom_claims)

    # Merge app-specific claims (highest priority)
    merged.merge!(application.custom_claims_for_user(self))

    merged
  end

  private

  def add_to_auto_assign_groups
    Group.auto_assign.each { |g| groups << g }
  end

  def revoke_sessions_when_deactivated
    return unless saved_change_to_status?
    return if active?

    sessions.destroy_all
  end

  def no_reserved_claim_names
    return if custom_claims.blank?

    reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
    if reserved_used.any?
      errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
    end
  end

  def generate_backup_codes
    # Generate plain codes for user to see/save
    plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }

    # Store BCrypt hashes of the codes
    hashed_codes = plain_codes.map { |code| BCrypt::Password.create(code) }

    # Return plain codes for display (will be shown to user once)
    # Store only hashes in the database (as Array for JSON column)
    self.backup_codes = hashed_codes

    plain_codes
  end
end
