require "test_helper"

class UserTest < ActiveSupport::TestCase
  include ActiveJob::TestHelper

  def setup
    @user = users(:family_admin)
  end

  def teardown
    clear_enqueued_jobs
    clear_performed_jobs
  end

  test "should be valid" do
    assert @user.valid?, @user.errors.full_messages.to_sentence
  end

  # email
  test "email must be present" do
    potential_user = User.new(
      email: "david@davidbowie.com",
      password_digest: BCrypt::Password.create("password"),
      first_name: "David",
      last_name: "Bowie"
    )
    potential_user.email = "     "
    assert_not potential_user.valid?
  end

  test "has email address" do
    assert_equal "bob@bobdylan.com", @user.email
  end

  test "can update email" do
    @user.update(email: "new_email@example.com")
    assert_equal "new_email@example.com", @user.email
  end

  test "email addresses must be unique" do
    duplicate_user = @user.dup
    duplicate_user.email = @user.email.upcase
    @user.save
    assert_not duplicate_user.valid?
  end

  test "email address is normalized" do
    @user.update!(email: " UNIQUE-User@ExAMPle.CoM ")
    assert_equal "unique-user@example.com", @user.reload.email
  end

  test "display name" do
    user = User.new(email: "user@example.com")
    assert_equal "user@example.com", user.display_name
    user.first_name = "Bob"
    assert_equal "Bob", user.display_name
    user.last_name = "Dylan"
    assert_equal "Bob Dylan", user.display_name
  end

  test "initial" do
    user = User.new(email: "user@example.com")
    assert_equal "U", user.initial
    user.first_name = "Bob"
    assert_equal "B", user.initial
    user.first_name = nil
    user.last_name = "Dylan"
    assert_equal "D", user.initial
  end

  test "names are normalized" do
    @user.update!(first_name: "", last_name: "")
    assert_nil @user.first_name
    assert_nil @user.last_name

    @user.update!(first_name: " Bob ", last_name: " Dylan ")
    assert_equal "Bob", @user.first_name
    assert_equal "Dylan", @user.last_name
  end

  # MFA Tests
  test "setup_mfa! generates required fields" do
    user = users(:family_member)
    user.setup_mfa!

    assert user.otp_secret.present?
    assert_not user.otp_required?
    assert_empty user.otp_backup_codes
  end

  test "enable_mfa! enables MFA and generates backup codes" do
    user = users(:family_member)
    user.setup_mfa!
    backup_codes = user.enable_mfa!

    assert user.otp_required?
    assert_equal 8, backup_codes.length
    assert backup_codes.all? { |code| code.match?(/\A[0-9a-f]{16}\z/) }
    assert_equal 8, user.otp_backup_codes.length
    assert user.otp_backup_codes.all? { |code| code.start_with?("$2") }
    assert_empty backup_codes & user.otp_backup_codes
  end

  test "enable_mfa! requires an OTP secret" do
    user = users(:family_member)
    user.setup_mfa!
    user.update_column(:otp_secret, nil)

    assert_raises(ArgumentError) { user.enable_mfa! }
    assert_not user.reload.otp_required?
    assert_empty user.otp_backup_codes
  end

  test "disable_mfa! removes all MFA data" do
    user = users(:family_member)
    user.setup_mfa!
    user.enable_mfa!
    user.webauthn_credentials.create!(
      nickname: "YubiKey",
      credential_id: "credential-id",
      public_key: "public-key"
    )

    user.disable_mfa!

    assert_nil user.otp_secret
    assert_not user.otp_required?
    assert_empty user.otp_backup_codes
    assert_empty user.webauthn_credentials
  end

  test "ensure_webauthn_id! generates a stable credential user handle" do
    user = users(:family_member)
    assert_nil user.webauthn_id

    webauthn_id = user.ensure_webauthn_id!

    assert webauthn_id.present?
    assert_equal webauthn_id, user.reload.ensure_webauthn_id!
  end

  test "webauthn_enabled? requires MFA and at least one credential" do
    user = users(:family_member)
    assert_not user.webauthn_enabled?

    user.setup_mfa!
    user.enable_mfa!
    assert_not user.webauthn_enabled?

    user.webauthn_credentials.create!(
      nickname: "Touch ID",
      credential_id: "touch-id-credential",
      public_key: "public-key"
    )

    assert user.webauthn_enabled?
  end

  test "verify_otp? validates TOTP codes" do
    user = users(:family_member)
    user.setup_mfa!

    totp = ROTP::TOTP.new(user.otp_secret, issuer: "Sure Finances")
    valid_code = totp.now

    assert user.verify_otp?(valid_code)
    assert_not user.verify_otp?("invalid")
    assert_not user.verify_otp?("123456")
  end

  test "verify_otp? does not check backup code digests for normal TOTP input" do
    user = users(:family_member)
    user.setup_mfa!
    user.enable_mfa!
    valid_code = ROTP::TOTP.new(user.otp_secret, issuer: "Sure Finances").now

    BCrypt::Password.expects(:new).never

    assert user.verify_otp?(valid_code)
  end

  test "verify_otp? fast rejects non-backup-code input before digest checks" do
    user = users(:family_member)
    user.setup_mfa!
    user.enable_mfa!

    BCrypt::Password.expects(:new).never

    assert_not user.verify_otp?("not-a-backup-code")
  end

  test "verify_otp? rejects unmatched legacy-shaped backup input" do
    user = users(:family_member)
    user.setup_mfa!
    user.enable_mfa!

    assert_not user.verify_otp?("deadbeef")
  end

  test "verify_otp? accepts backup codes" do
    user = users(:family_member)
    user.setup_mfa!
    backup_codes = user.enable_mfa!

    backup_code = backup_codes.first
    matching_digest = user.otp_backup_codes.find { |digest| BCrypt::Password.new(digest).is_password?(backup_code) }
    assert_not_nil matching_digest

    assert user.verify_otp?(backup_code)

    # Backup code should be consumed
    assert_equal 7, user.otp_backup_codes.length
    assert_not_includes user.otp_backup_codes, matching_digest

    # Used backup code should not work again
    assert_not user.verify_otp?(backup_code)
  end

  test "verify_otp? reloads backup codes while consuming under lock" do
    user = users(:family_member)
    user.setup_mfa!
    backup_code = user.enable_mfa!.first
    stale_user = User.find(user.id)

    user.update!(otp_backup_codes: [])

    assert_not stale_user.verify_otp?(backup_code)
    assert_empty stale_user.reload.otp_backup_codes
  end

  test "verify_otp? accepts and consumes legacy plaintext backup codes once" do
    user = users(:family_member)
    user.setup_mfa!
    user.update!(otp_required: true, otp_backup_codes: [ "deadbeef" ])

    assert user.verify_otp?("deadbeef")

    assert_empty user.reload.otp_backup_codes
    assert_not user.verify_otp?("deadbeef")
  end

  test "verify_otp? accepts and consumes migrated legacy backup code digests" do
    user = users(:family_member)
    user.setup_mfa!
    user.update!(
      otp_required: true,
      otp_backup_codes: [ BCrypt::Password.create("deadbeef", cost: BCrypt::Engine::MIN_COST).to_s ]
    )

    assert user.verify_otp?("deadbeef")

    assert_empty user.reload.otp_backup_codes
    assert_not user.verify_otp?("deadbeef")
  end

  test "provisioning_uri generates correct URI" do
    user = users(:family_member)
    user.setup_mfa!

    assert_match %r{otpauth://totp/}, user.provisioning_uri
    assert_match %r{secret=#{user.otp_secret}}, user.provisioning_uri
    assert_match %r{issuer=Sure}, user.provisioning_uri
  end

  test "ai_available? returns true when openai access token set in settings" do
    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
    previous = Setting.openai_access_token
    with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
      Setting.openai_access_token = nil
      assert_not @user.ai_available?

      Setting.openai_access_token = "token"
      assert @user.ai_available?
    end
  ensure
    Setting.openai_access_token = previous
  end

  test "ai_available? returns true when external assistant is configured and family type is external" do
    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
    previous = Setting.openai_access_token
    @user.family.update!(assistant_type: "external")
    with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
      Setting.openai_access_token = nil
      assert @user.ai_available?
    end
  ensure
    Setting.openai_access_token = previous
    @user.family.update!(assistant_type: "builtin")
  end

  test "ai_available? returns false when external assistant is configured but family type is builtin" do
    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
    previous = Setting.openai_access_token
    with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
      Setting.openai_access_token = nil
      assert_not @user.ai_available?
    end
  ensure
    Setting.openai_access_token = previous
  end

  test "ai_available? returns false when external assistant is configured but user is not in allowlist" do
    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
    previous = Setting.openai_access_token
    @user.family.update!(assistant_type: "external")
    with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token", EXTERNAL_ASSISTANT_ALLOWED_EMAILS: "other@example.com" do
      Setting.openai_access_token = nil
      assert_not @user.ai_available?
    end
  ensure
    Setting.openai_access_token = previous
    @user.family.update!(assistant_type: "builtin")
  end

  test "intro layout collapses sidebars and enables ai" do
    user = User.new(
      family: families(:empty),
      email: "intro-new@example.com",
      password: "Password1!",
      password_confirmation: "Password1!",
      role: :guest,
      ui_layout: :intro
    )

    assert user.save, user.errors.full_messages.to_sentence
    assert user.ui_layout_intro?
    assert_not user.show_sidebar?
    assert_not user.show_ai_sidebar?
    assert user.ai_enabled?
  end

  test "non-guest role cannot persist intro layout" do
    user = User.new(
      family: families(:empty),
      email: "dashboard-only@example.com",
      password: "Password1!",
      password_confirmation: "Password1!",
      role: :member,
      ui_layout: :intro
    )

    assert user.save, user.errors.full_messages.to_sentence
    assert user.ui_layout_dashboard?
  end

  test "upgrading guest role restores dashboard layout defaults" do
    user = users(:intro_user)
    user.update!(role: :member)
    user.reload

    assert user.ui_layout_dashboard?
    assert user.show_sidebar?
    assert user.show_ai_sidebar?
  end

  test "new member defaults show_ai_sidebar to false when AI is not available" do
    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
    previous = Setting.openai_access_token
    with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
      Setting.openai_access_token = nil
      user = User.new(
        family: families(:empty),
        email: "member-no-ai@example.com",
        password: "Password1!",
        password_confirmation: "Password1!",
        role: :member
      )
      assert user.save, user.errors.full_messages.to_sentence
      assert_not user.show_ai_sidebar?
    end
  ensure
    Setting.openai_access_token = previous
  end

  test "new admin defaults show_ai_sidebar to true even when AI is not available" do
    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
    previous = Setting.openai_access_token
    with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
      Setting.openai_access_token = nil
      user = User.new(
        family: families(:empty),
        email: "admin-no-ai@example.com",
        password: "Password1!",
        password_confirmation: "Password1!",
        role: :admin
      )
      assert user.save, user.errors.full_messages.to_sentence
      assert user.show_ai_sidebar?
    end
  ensure
    Setting.openai_access_token = previous
  end

  test "new member defaults show_ai_sidebar to true when AI is available" do
    Rails.application.config.app_mode.stubs(:self_hosted?).returns(false)
    user = User.new(
      family: families(:empty),
      email: "member-with-ai@example.com",
      password: "Password1!",
      password_confirmation: "Password1!",
      role: :member
    )
    assert user.save, user.errors.full_messages.to_sentence
    assert user.show_ai_sidebar?
  end

  test "new guest defaults show_ai_sidebar to false when AI is not available" do
    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
    previous = Setting.openai_access_token
    with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
      Setting.openai_access_token = nil
      user = User.new(
        family: families(:empty),
        email: "guest-no-ai@example.com",
        password: "Password1!",
        password_confirmation: "Password1!",
        role: :guest
      )
      assert user.save, user.errors.full_messages.to_sentence
      assert_not user.show_ai_sidebar?
    end
  ensure
    Setting.openai_access_token = previous
  end

  test "new guest defaults show_ai_sidebar to false when AI is available" do
    Rails.application.config.app_mode.stubs(:self_hosted?).returns(false)
    user = User.new(
      family: families(:empty),
      email: "guest-with-ai@example.com",
      password: "Password1!",
      password_confirmation: "Password1!",
      role: :guest
    )
    assert user.save, user.errors.full_messages.to_sentence
    assert_not user.show_ai_sidebar?
  end

  test "update_dashboard_preferences handles concurrent updates atomically" do
    @user.update!(preferences: {})

    # Simulate concurrent updates from multiple requests
    # Each thread collapses a different section simultaneously
    threads = []
    sections = %w[net_worth_chart outflows_donut cashflow_sankey balance_sheet]

    sections.each_with_index do |section, index|
      threads << Thread.new do
        # Small staggered delays to increase chance of race conditions
        sleep(index * 0.01)

        # Each thread loads its own instance and updates
        user = User.find(@user.id)
        user.update_dashboard_preferences({
          "collapsed_sections" => { section => true }
        })
      end
    end

    # Wait for all threads to complete
    threads.each(&:join)

    # Verify all updates persisted (no data loss from race conditions)
    @user.reload
    sections.each do |section|
      assert @user.dashboard_section_collapsed?(section),
        "Expected #{section} to be collapsed, but it was not. " \
        "Preferences: #{@user.preferences.inspect}"
    end

    # Verify all sections are in the preferences hash
    assert_equal sections.sort,
      @user.preferences.dig("collapsed_sections")&.keys&.sort,
      "Expected all sections to be in preferences"
  end

  test "update_dashboard_preferences merges nested hashes correctly" do
    @user.update!(preferences: {})

    # First update: collapse net_worth
    @user.update_dashboard_preferences({
      "collapsed_sections" => { "net_worth_chart" => true }
    })
    @user.reload

    assert @user.dashboard_section_collapsed?("net_worth_chart")
    assert_not @user.dashboard_section_collapsed?("outflows_donut")

    # Second update: collapse outflows (should preserve net_worth)
    @user.update_dashboard_preferences({
      "collapsed_sections" => { "outflows_donut" => true }
    })
    @user.reload

    assert @user.dashboard_section_collapsed?("net_worth_chart"),
      "First collapsed section should still be collapsed"
    assert @user.dashboard_section_collapsed?("outflows_donut"),
      "Second collapsed section should be collapsed"
  end

  test "update_dashboard_preferences handles section_order updates" do
    @user.update!(preferences: {})

    # Set initial order
    new_order = %w[outflows_donut net_worth_chart cashflow_sankey balance_sheet]
    @user.update_dashboard_preferences({ "section_order" => new_order })
    @user.reload

    assert_equal new_order, @user.dashboard_section_order
  end

  test "handles empty preferences gracefully for dashboard methods" do
    @user.update!(preferences: {})

    # dashboard_section_collapsed? should return false when key is missing
    assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
      "Should return false when collapsed_sections key is missing"

    # dashboard_section_order should return default order when key is missing
    assert_equal %w[cashflow_sankey outflows_donut net_worth_chart balance_sheet],
      @user.dashboard_section_order,
      "Should return default order when section_order key is missing"

    # update_dashboard_preferences should work with empty preferences
    @user.update_dashboard_preferences({ "section_order" => %w[balance_sheet] })
    @user.reload

    assert_equal %w[balance_sheet], @user.preferences["section_order"]
  end

  test "handles empty preferences gracefully for reports methods" do
    @user.update!(preferences: {})

    # reports_section_collapsed? should return false when key is missing
    assert_not @user.reports_section_collapsed?("trends_insights"),
      "Should return false when reports_collapsed_sections key is missing"

    # reports_section_order should return default order when key is missing
    assert_equal %w[trends_insights transactions_breakdown],
      @user.reports_section_order,
      "Should return default order when reports_section_order key is missing"

    # update_reports_preferences should work with empty preferences
    @user.update_reports_preferences({ "reports_section_order" => %w[transactions_breakdown] })
    @user.reload

    assert_equal %w[transactions_breakdown], @user.preferences["reports_section_order"]
  end

  test "handles missing nested keys in preferences for collapsed sections" do
    @user.update!(preferences: { "section_order" => %w[cashflow] })

    # Should return false when collapsed_sections key is missing entirely
    assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
      "Should return false when collapsed_sections key is missing"

    # Should return false when section_key is missing from collapsed_sections
    @user.update!(preferences: { "collapsed_sections" => {} })
    assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
      "Should return false when section key is missing from collapsed_sections"
  end

  # Default account for transactions
  test "default_account_for_transactions returns account when active and manual" do
    account = accounts(:depository)
    @user.update!(default_account: account)
    assert_equal account, @user.default_account_for_transactions
  end

  test "default_account_for_transactions returns nil when account is disabled" do
    account = accounts(:depository)
    @user.update!(default_account: account)
    account.disable!
    assert_nil @user.default_account_for_transactions
  end

  test "default_account_for_transactions returns nil when account is linked" do
    account = accounts(:depository)
    @user.update!(default_account: account)
    plaid_account = plaid_accounts(:one)
    AccountProvider.create!(account: account, provider: plaid_account)
    account.reload
    assert_nil @user.default_account_for_transactions
  end

  test "default_account_for_transactions returns nil when no default set" do
    assert_nil @user.default_account_for_transactions
  end

  # SSO-only user security tests
  test "sso_only? returns true for user with OIDC identity and no password" do
    sso_user = users(:sso_only)
    assert_nil sso_user.password_digest
    assert sso_user.oidc_identities.exists?
    assert sso_user.sso_only?
  end

  test "sso_only? returns false for user with password and OIDC identity" do
    # family_admin has both password and OIDC identity
    assert @user.password_digest.present?
    assert @user.oidc_identities.exists?
    assert_not @user.sso_only?
  end

  test "sso_only? returns false for user with password but no OIDC identity" do
    user_without_oidc = users(:empty)
    assert user_without_oidc.password_digest.present?
    assert_not user_without_oidc.oidc_identities.exists?
    assert_not user_without_oidc.sso_only?
  end

  test "has_local_password? returns true when password_digest is present" do
    assert @user.has_local_password?
  end

  test "has_local_password? returns false when password_digest is nil" do
    sso_user = users(:sso_only)
    assert_not sso_user.has_local_password?
  end

  test "user can be created without password when skip_password_validation is true" do
    user = User.new(
      email: "newssuser@example.com",
      first_name: "New",
      last_name: "SSO User",
      skip_password_validation: true,
      family: families(:empty)
    )
    assert user.valid?, user.errors.full_messages.to_sentence
    assert user.save
    assert_nil user.password_digest
  end

  test "user requires password on create when skip_password_validation is false" do
    user = User.new(
      email: "needspassword@example.com",
      first_name: "Needs",
      last_name: "Password",
      family: families(:empty)
    )
    assert_not user.valid?
    assert_includes user.errors[:password], "can't be blank"
  end

  # First user role assignment tests
  test "role_for_new_family_creator returns super_admin when no users exist" do
    # Delete all users to simulate fresh instance
    User.destroy_all

    assert_equal :super_admin, User.role_for_new_family_creator
  end

  test "role_for_new_family_creator returns fallback role when users exist" do
    # Users exist from fixtures
    assert User.exists?

    assert_equal :admin, User.role_for_new_family_creator
    assert_equal :member, User.role_for_new_family_creator(fallback_role: :member)
    assert_equal "custom_role", User.role_for_new_family_creator(fallback_role: "custom_role")
  end

  # ActiveStorage attachment cleanup tests
  test "purging a user removes attached profile image" do
    user = users(:family_admin)
    user.profile_image.attach(
      io: StringIO.new("profile-image-data"),
      filename: "profile.png",
      content_type: "image/png"
    )

    attachment_id = user.profile_image.id
    assert ActiveStorage::Attachment.exists?(attachment_id)

    perform_enqueued_jobs do
      user.purge
    end

    assert_not User.exists?(user.id)
    assert_not ActiveStorage::Attachment.exists?(attachment_id)
  end

  test "purging the last user cascades to remove family and its export attachments" do
    family = Family.create!(name: "Solo Family", locale: "en", date_format: "%m-%d-%Y", currency: "USD")
    user = User.create!(family: family, email: "solo@example.com", password: "password123")
    export = family.family_exports.create!
    export.export_file.attach(
      io: StringIO.new("export-data"),
      filename: "export.zip",
      content_type: "application/zip"
    )

    export_attachment_id = export.export_file.id
    assert ActiveStorage::Attachment.exists?(export_attachment_id)

    perform_enqueued_jobs do
      user.purge
    end

    assert_not Family.exists?(family.id)
    assert_not ActiveStorage::Attachment.exists?(export_attachment_id)
  end
end
