defmodule Towerops.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false

  alias Towerops.Accounts.BrowserSession
  alias Towerops.Accounts.LoginAttempt
  alias Towerops.Accounts.PolicyVersion
  alias Towerops.Accounts.User
  alias Towerops.Accounts.UserAgentParser
  alias Towerops.Accounts.UserConsent
  alias Towerops.Accounts.UserNotifier
  alias Towerops.Accounts.UserRecoveryCode
  alias Towerops.Accounts.UserToken
  alias Towerops.Accounts.UserTotpDevice
  alias Towerops.Admin.AuditLogger
  alias Towerops.GeoIP
  alias Towerops.Repo

  ## Database getters

  @doc """
  Gets a user by email.

  ## Examples

      iex> get_user_by_email("foo@example.com")
      %User{}

      iex> get_user_by_email("unknown@example.com")
      nil

  """
  @spec get_user_by_email(String.t()) :: User.t() | nil
  def get_user_by_email(email) when is_binary(email) do
    Repo.get_by(User, email: email)
  end

  @doc """
  Gets a user by email and password.

  ## Examples

      iex> get_user_by_email_and_password("foo@example.com", "correct_password")
      %User{}

      iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
      nil

  """
  @spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | nil
  def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do
    user = Repo.get_by(User, email: email)
    if User.valid_password?(user, password), do: user
  end

  @doc """
  Gets a single user.

  Returns `nil` if the User does not exist.

  ## Examples

      iex> get_user("123")
      %User{}

      iex> get_user("456")
      nil

  """
  @spec get_user(String.t()) :: User.t() | nil
  def get_user(id) when is_binary(id) do
    Repo.get(User, id)
  end

  @doc """
  Gets a single user.

  Raises `Ecto.NoResultsError` if the User does not exist.

  ## Examples

      iex> get_user!(123)
      %User{}

      iex> get_user!(456)
      ** (Ecto.NoResultsError)

  """
  @spec get_user!(String.t()) :: User.t()
  def get_user!(id), do: Repo.get!(User, id)

  ## User registration

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking user registration changes.

  ## Examples

      iex> change_user_registration(user)
      %Ecto.Changeset{data: %User{}}

  """
  @spec change_user_registration(User.t(), map()) :: Ecto.Changeset.t()
  def change_user_registration(%User{} = user, attrs \\ %{}) do
    User.registration_changeset(user, attrs, hash_password: false, validate_unique: false)
  end

  @doc """
  Registers a user with email and password.

  ## Examples

      iex> register_user(%{field: value})
      {:ok, %User{}}

      iex> register_user(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  @spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
  def register_user(attrs) do
    user_changeset =
      %User{}
      |> User.registration_changeset(attrs)
      |> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))

    multi = Ecto.Multi.new()
    multi = Ecto.Multi.insert(multi, :user, user_changeset)

    # Grant consents if provided
    multi = grant_consents_on_registration(multi, attrs)

    case Repo.transaction(multi) do
      {:ok, %{user: user}} -> {:ok, user}
      {:error, :user, changeset, _} -> {:error, changeset}
      {:error, _name, _changeset, _} -> {:error, user_changeset}
    end
  end

  @doc """
  Registers a user with email and password, and creates a default organization.

  ## Examples

      iex> register_user_with_organization(%{field: value})
      {:ok, %User{}}

      iex> register_user_with_organization(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  @dialyzer {:nowarn_function, register_user_with_organization: 1}
  @spec register_user_with_organization(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
  def register_user_with_organization(attrs) do
    user_changeset =
      %User{}
      |> User.registration_changeset(attrs)
      |> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))

    multi = Ecto.Multi.new()
    multi = Ecto.Multi.insert(multi, :user, user_changeset)

    multi =
      Ecto.Multi.run(multi, :organization, fn _repo, %{user: user} ->
        org_name = attrs["organization_name"] || "Personal"

        Towerops.Organizations.create_organization(
          %{name: org_name},
          user.id
        )
      end)

    # Grant consents if provided
    multi = grant_consents_on_registration(multi, attrs)

    case Repo.transaction(multi) do
      {:ok, %{user: user}} -> {:ok, user}
      {:error, :user, changeset, _} -> {:error, changeset}
      {:error, :organization, changeset, _} -> {:error, changeset}
      {:error, _name, _changeset, _} -> {:error, user_changeset}
    end
  end

  defp grant_consents_on_registration(multi, attrs) do
    # Only grant consents if checkboxes were checked
    privacy_consent = attrs["privacy_policy_consent"] || attrs[:privacy_policy_consent]
    terms_consent = attrs["terms_of_service_consent"] || attrs[:terms_of_service_consent]

    multi =
      if privacy_consent in ["true", true, "on"] do
        Ecto.Multi.run(multi, :privacy_policy_consent, fn _repo, %{user: user} ->
          grant_consent(user.id, "privacy_policy")
        end)
      else
        multi
      end

    if terms_consent in ["true", true, "on"] do
      Ecto.Multi.run(multi, :terms_of_service_consent, fn _repo, %{user: user} ->
        grant_consent(user.id, "terms_of_service")
      end)
    else
      multi
    end
  end

  ## TOTP / Two-Factor Authentication

  @doc """
  Generates a new TOTP secret for a user.

  Returns a binary secret that should be stored temporarily until verified.
  """
  def generate_totp_secret do
    NimbleTOTP.secret()
  end

  @doc """
  Generates an otpauth URI for displaying as a QR code.

  ## Examples

      iex> generate_totp_uri(user, secret)
      "otpauth://totp/Towerops:user@example.com?secret=SECRET&issuer=Towerops"
  """
  def generate_totp_uri(%User{} = user, secret) do
    NimbleTOTP.otpauth_uri("Towerops:#{user.email}", secret, issuer: "Towerops")
  end

  @doc """
  Generates a QR code as a data URI for the TOTP URI.

  Returns a base64-encoded PNG image as a data URI that can be displayed in an img tag.
  """
  def generate_totp_qr_code(%User{} = user, secret) do
    uri = generate_totp_uri(user, secret)

    uri
    |> EQRCode.encode()
    |> EQRCode.png()
    |> Base.encode64()
    |> then(&"data:image/png;base64,#{&1}")
  end

  @doc """
  Verifies a TOTP code against a secret.

  Returns true if the code is valid within the time window, false otherwise.
  Allows for +/- 4 time steps (120 seconds / 2 minutes) to handle clock drift.
  """
  def verify_totp(secret, code) when is_binary(secret) and is_binary(code) do
    current_time = System.system_time(:second)

    # Check multiple time windows to handle clock drift
    # Check ±4 time steps (±120 seconds / 2 minutes)
    time_windows = [
      current_time,
      current_time - 30,
      current_time + 30,
      current_time - 60,
      current_time + 60,
      current_time - 90,
      current_time + 90,
      current_time - 120,
      current_time + 120
    ]

    result =
      Enum.any?(time_windows, fn time ->
        NimbleTOTP.valid?(secret, code, time: time)
      end)

    if !result do
      require Logger
      # Log detailed debugging info when TOTP fails
      expected_code_current = NimbleTOTP.verification_code(secret, time: current_time)
      expected_code_prev = NimbleTOTP.verification_code(secret, time: current_time - 30)
      expected_code_next = NimbleTOTP.verification_code(secret, time: current_time + 30)
      current_datetime = DateTime.from_unix!(current_time)

      Logger.info(
        "TOTP verification failed: " <>
          "provided=#{code} (len=#{String.length(code)}), " <>
          "expected_current=#{expected_code_current}, " <>
          "expected_prev=#{expected_code_prev} (-30s), " <>
          "expected_next=#{expected_code_next} (+30s), " <>
          "server_time=#{current_datetime} (unix=#{current_time}), " <>
          "secret_len=#{String.length(secret)}"
      )
    end

    result
  end

  @doc """
  Enables TOTP for a user by saving the secret.

  The secret should have been verified before calling this function.
  """
  def enable_totp(%User{} = user, secret) when is_binary(secret) do
    user
    |> Ecto.Changeset.change(totp_secret: secret)
    |> Repo.update()
  end

  @doc """
  Checks if a user has TOTP enabled.

  Checks both the new multi-device system (user_totp_devices table)
  and the legacy totp_secret field for backward compatibility.
  """
  def totp_enabled?(%User{id: user_id, totp_secret: secret}) do
    # Check new multi-device system first
    device_count = count_user_totp_devices(user_id)

    cond do
      device_count > 0 -> true
      is_binary(secret) -> true
      true -> false
    end
  end

  def totp_enabled?(_user), do: false

  @doc """
  Verifies a TOTP code for a user who has TOTP enabled.

  Now checks ALL user devices (multi-device support), not just single secret.
  Falls back to legacy single secret for backward compatibility.
  Returns {:ok, user} if valid, {:error, :invalid_code} otherwise.
  """
  def verify_user_totp(%User{} = user, code) when is_binary(code) do
    # First try new multi-device approach
    case verify_user_totp_any_device(user, code) do
      {:ok, _device} ->
        {:ok, user}

      {:error, :invalid_code} ->
        # Fallback to legacy single secret (backward compatibility during migration)
        verify_legacy_totp(user, code)
    end
  end

  defp verify_legacy_totp(%User{totp_secret: secret} = user, code) when is_binary(secret) do
    if verify_totp(secret, code) do
      {:ok, user}
    else
      {:error, :invalid_code}
    end
  end

  defp verify_legacy_totp(%User{}, _code) do
    {:error, :totp_not_enabled}
  end

  ## TOTP Device Management

  @doc """
  Lists all TOTP devices for a user, ordered by most recently used.
  """
  def list_user_totp_devices(user_id) do
    UserTotpDevice
    |> where([d], d.user_id == ^user_id)
    |> order_by([d], desc_nulls_last: d.last_used_at, desc: d.inserted_at)
    |> Repo.all()
  end

  @doc """
  Counts TOTP devices for a user.
  """
  def count_user_totp_devices(user_id) do
    UserTotpDevice
    |> where([d], d.user_id == ^user_id)
    |> Repo.aggregate(:count, :id)
  end

  @doc """
  Creates a new TOTP device for a user.

  Returns {:ok, device, secret} where secret is the plain-text TOTP secret
  that should be displayed once to the user as a QR code.

  ## Examples

      iex> create_totp_device(user_id, "iPhone 15 Pro")
      {:ok, %UserTotpDevice{}, "base32secret"}
  """
  def create_totp_device(user_id, device_name) do
    secret = generate_totp_secret()
    create_totp_device(user_id, device_name, secret)
  end

  @doc """
  Creates a TOTP device with a pre-existing secret.
  Used during initial enrollment when the secret has already been generated and shown to the user.
  """
  def create_totp_device(user_id, device_name, secret) when is_binary(secret) do
    changeset =
      UserTotpDevice.changeset(%UserTotpDevice{}, %{
        user_id: user_id,
        name: device_name,
        totp_secret: secret,
        created_at: DateTime.utc_now(:second)
      })

    case Repo.insert(changeset) do
      {:ok, device} -> {:ok, device, secret}
      {:error, changeset} -> {:error, changeset}
    end
  end

  @doc """
  Verifies a TOTP code against ANY of the user's devices.

  Returns {:ok, device} if valid, updating last_used_at.
  Returns {:error, :invalid_code} if no device matches.
  """
  def verify_user_totp_any_device(%User{id: user_id}, code) when is_binary(code) do
    devices = list_user_totp_devices(user_id)

    Enum.find_value(devices, {:error, :invalid_code}, fn device ->
      if verify_totp(device.totp_secret, code) do
        # Update last_used_at and return updated device
        updated_device =
          device
          |> UserTotpDevice.touch_changeset()
          |> Repo.update!()

        {:ok, updated_device}
      end
    end)
  end

  @doc """
  Deletes a TOTP device.

  Enforces "at least one device" rule - returns error if last device.
  """
  def delete_totp_device(device_id, user_id) do
    device = Repo.get(UserTotpDevice, device_id)

    cond do
      is_nil(device) ->
        {:error, :not_found}

      device.user_id != user_id ->
        {:error, :unauthorized}

      count_user_totp_devices(user_id) == 1 ->
        {:error, :last_device}

      true ->
        Repo.delete(device)
    end
  end

  @doc """
  Renames a TOTP device.
  """
  def rename_totp_device(device_id, user_id, new_name) do
    device = Repo.get(UserTotpDevice, device_id)

    cond do
      is_nil(device) ->
        {:error, :not_found}

      device.user_id != user_id ->
        {:error, :unauthorized}

      true ->
        device
        |> UserTotpDevice.changeset(%{name: new_name})
        |> Repo.update()
    end
  end

  ## Recovery Codes

  @doc """
  Generates 12 recovery codes for a user.

  Returns {:ok, codes} where codes is a list of plain-text codes.
  Stores hashed versions in database.
  """
  def generate_recovery_codes(user_id) do
    # Delete any existing unused recovery codes
    UserRecoveryCode
    |> where([rc], rc.user_id == ^user_id and is_nil(rc.used_at))
    |> Repo.delete_all()

    now = DateTime.utc_now(:second)
    codes = for _ <- 1..12, do: UserRecoveryCode.generate_code()

    # Insert hashed codes
    records =
      Enum.map(codes, fn code ->
        %{
          id: Ecto.UUID.generate(),
          user_id: user_id,
          code_hash: UserRecoveryCode.hash_code(code),
          created_at: now,
          inserted_at: now
        }
      end)

    case Repo.insert_all(UserRecoveryCode, records) do
      {12, _} -> {:ok, codes}
      _ -> {:error, :generation_failed}
    end
  end

  @doc """
  Verifies a recovery code for a user.

  Returns {:ok, code_record} if valid and unused.
  Marks code as used on successful verification.
  """
  def verify_recovery_code(user_id, code) when is_binary(code) do
    code_hash = UserRecoveryCode.hash_code(code)

    recovery_code =
      UserRecoveryCode
      |> where([rc], rc.user_id == ^user_id)
      |> where([rc], rc.code_hash == ^code_hash)
      |> where([rc], is_nil(rc.used_at))
      |> Repo.one()

    case recovery_code do
      nil ->
        {:error, :invalid_code}

      code_record ->
        code_record
        |> UserRecoveryCode.use_changeset()
        |> Repo.update()
    end
  end

  @doc """
  Counts unused recovery codes for a user.
  """
  def count_unused_recovery_codes(user_id) do
    UserRecoveryCode
    |> where([rc], rc.user_id == ^user_id)
    |> where([rc], is_nil(rc.used_at))
    |> Repo.aggregate(:count, :id)
  end

  @doc """
  Lists all recovery codes for a user (for display in UI).

  Returns list with status (used/unused) and creation date.
  """
  def list_user_recovery_codes(user_id) do
    UserRecoveryCode
    |> where([rc], rc.user_id == ^user_id)
    |> order_by([rc], desc: rc.inserted_at)
    |> Repo.all()
  end

  @doc """
  Verifies a TOTP code OR recovery code during login.

  Tries TOTP first, falls back to recovery code.
  Returns {:ok, user, :totp} or {:ok, user, :recovery_code} or {:error, :invalid_code}.
  """
  def verify_user_mfa(%User{} = user, code) when is_binary(code) do
    case verify_user_totp(user, code) do
      {:ok, user} ->
        {:ok, user, :totp}

      {:error, :invalid_code} ->
        # Try recovery code as fallback
        case verify_recovery_code(user.id, code) do
          {:ok, _code_record} -> {:ok, user, :recovery_code}
          {:error, _} -> {:error, :invalid_code}
        end
    end
  end

  @doc """
  Verifies a TOTP code for a user, rejecting recovery codes.

  This is used for sudo mode verification where we want to ensure
  the user has access to their authenticator device, not just a recovery code.

  Returns `{:ok, user}` on success with valid TOTP code.
  Returns `{:error, :recovery_code_not_allowed}` if a recovery code is provided.
  Returns `{:error, reason}` for invalid TOTP codes.

  ## Examples

      iex> verify_totp_only(user, "123456")
      {:ok, %User{}}

      iex> verify_totp_only(user, "ABCD-EFGH-IJKL")
      {:error, :recovery_code_not_allowed}

      iex> verify_totp_only(user, "000000")
      {:error, :invalid_code}
  """
  def verify_totp_only(%User{} = user, code) when is_binary(code) do
    # TOTP codes are exactly 6 numeric digits
    # Recovery codes contain letters and/or are longer than 6 digits
    cond do
      # Reject if code contains any non-numeric characters
      not String.match?(code, ~r/^\d+$/) ->
        {:error, :recovery_code_not_allowed}

      # Reject if longer than 6 digits
      String.length(code) > 6 ->
        {:error, :recovery_code_not_allowed}

      # Looks like a TOTP code, verify it
      true ->
        case verify_user_mfa(user, code) do
          {:ok, user, :totp} -> {:ok, user}
          {:ok, _user, :recovery_code} -> {:error, :recovery_code_not_allowed}
          {:error, reason} -> {:error, reason}
        end
    end
  end

  ## Settings

  @doc """
  Returns an `%Ecto.Changeset{}` for changing the user profile.

  ## Examples

      iex> change_user_profile(user)
      %Ecto.Changeset{data: %User{}}

  """
  def change_user_profile(user, attrs \\ %{}) do
    User.profile_changeset(user, attrs)
  end

  @doc """
  Updates the user profile.

  ## Examples

      iex> update_user_profile(user, %{name: ...})
      {:ok, %User{}}

      iex> update_user_profile(user, %{name: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_user_profile(user, attrs) do
    user
    |> User.profile_changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Checks whether the user is in sudo mode.

  The user is in sudo mode when the last sudo verification was done no further
  than the specified minutes ago. Default is 10 minutes (pass -10 as the minutes arg).
  """
  def sudo_mode?(user, minutes \\ -10)

  def sudo_mode?(%User{last_sudo_at: ts}, minutes) when is_struct(ts, DateTime) do
    DateTime.after?(ts, DateTime.add(DateTime.utc_now(), minutes, :minute))
  end

  def sudo_mode?(_user, _minutes), do: false

  @doc """
  Grants sudo mode to a user by updating their last_sudo_at timestamp.

  Returns `{:ok, user}` on success or `{:error, changeset}` on failure.

  ## Examples

      iex> grant_sudo_mode(user)
      {:ok, %User{last_sudo_at: ~U[2026-02-01 12:00:00Z]}}
  """
  def grant_sudo_mode(%User{} = user) do
    now = DateTime.truncate(DateTime.utc_now(), :second)

    with {:ok, updated_user} <-
           user
           |> User.sudo_changeset(%{last_sudo_at: now})
           |> Repo.update() do
      # Log sudo mode grant for security auditing
      AuditLogger.log_event("sudo_mode_granted", nil,
        actor_id: user.id,
        target_user_id: user.id,
        metadata: %{granted_at: now}
      )

      # Set authenticated_at virtual field for sudo_mode? check
      {:ok, %{updated_user | authenticated_at: now}}
    end
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for changing the user email.

  See `Towerops.Accounts.User.email_changeset/3` for a list of supported options.

  ## Examples

      iex> change_user_email(user)
      %Ecto.Changeset{data: %User{}}

  """
  def change_user_email(user, attrs \\ %{}, opts \\ []) do
    User.email_changeset(user, attrs, opts)
  end

  @doc """
  Updates the user email using the given token.

  If the token matches, the user email is updated and the token is deleted.
  """
  def update_user_email(user, token) do
    context = "change:#{user.email}"

    Repo.transact(fn ->
      with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
           %UserToken{sent_to: email} <- Repo.one(query),
           {:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
           {_count, _result} <-
             Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
        {:ok, user}
      else
        _ -> {:error, :transaction_aborted}
      end
    end)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for changing the user password.

  See `Towerops.Accounts.User.password_changeset/3` for a list of supported options.

  ## Examples

      iex> change_user_password(user)
      %Ecto.Changeset{data: %User{}}

  """
  def change_user_password(user, attrs \\ %{}, opts \\ []) do
    User.password_changeset(user, attrs, opts)
  end

  @doc """
  Updates the user password.

  Returns a tuple with the updated user, as well as a list of expired tokens.

  ## Examples

      iex> update_user_password(user, %{password: ...})
      {:ok, {%User{}, [...]}}

      iex> update_user_password(user, %{password: "too short"})
      {:error, %Ecto.Changeset{}}

  """
  def update_user_password(user, attrs) do
    user
    |> User.password_changeset(attrs)
    |> update_user_and_delete_all_tokens()
  end

  @doc """
  Delivers the reset password email to the given user.

  ## Examples

      iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/\#{&1}"))
      {:ok, %{to: ..., body: ...}}

  """
  def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
      when is_function(reset_password_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
    Repo.insert!(user_token)
    UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
  end

  @doc """
  Gets the user by reset password token.

  ## Examples

      iex> get_user_by_reset_password_token("validtoken")
      %User{}

      iex> get_user_by_reset_password_token("invalidtoken")
      nil

  """
  def get_user_by_reset_password_token(token) do
    with {:ok, query} <- UserToken.verify_reset_password_token_query(token),
         %User{} = user <- Repo.one(query) do
      user
    else
      _ -> nil
    end
  end

  @doc """
  Resets the user password.

  ## Examples

      iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
      {:ok, %User{}}

      iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
      {:error, %Ecto.Changeset{}}

  """
  def reset_user_password(user, attrs) do
    Repo.transact(fn ->
      changeset = User.password_changeset(user, attrs)

      with {:ok, {updated_user, expired_tokens}} <- update_user_and_delete_all_tokens(changeset) do
        # Delete all reset_password tokens after successful password update
        Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id and t.context == "reset_password"))
        {:ok, {updated_user, expired_tokens}}
      end
    end)
  end

  ## Session

  @doc """
  Generates a session token.
  """
  def generate_user_session_token(user) do
    {token, user_token} = UserToken.build_session_token(user)
    Repo.insert!(user_token)
    token
  end

  @doc """
  Generates a session token and returns both the token and the user_token record.

  This is used when creating browser sessions to link them to the token ID.
  """
  def generate_user_session_token_with_record(user) do
    {token, user_token_changeset} = UserToken.build_session_token(user)
    user_token = Repo.insert!(user_token_changeset)
    {token, user_token}
  end

  @doc """
  Gets the user with the given signed token.

  If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
  """
  def get_user_by_session_token(token) do
    {:ok, query} = UserToken.verify_session_token_query(token)
    Repo.one(query)
  end

  @doc """
  Gets the user token ID for a given session token value.

  Returns the token ID if found, otherwise `nil`.
  """
  def get_user_token_id_by_value(token) when is_binary(token) do
    hashed_token = :crypto.hash(:sha256, token)

    Repo.one(from(ut in UserToken, where: ut.token == ^hashed_token and ut.context == "session", select: ut.id))
  end

  @doc """
  Gets the user with the given magic link token.
  """
  def get_user_by_magic_link_token(token) do
    with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
         {user, _token} <- Repo.one(query) do
      user
    else
      _ -> nil
    end
  end

  @doc """
  Logs the user in by magic link.

  There are three cases to consider:

  1. The user has already confirmed their email. They are logged in
     and the magic link is expired.

  2. The user has not confirmed their email and no password is set.
     In this case, the user gets confirmed, logged in, and all tokens -
     including session ones - are expired. In theory, no other tokens
     exist but we delete all of them for best security practices.

  3. The user has not confirmed their email but a password is set.
     This cannot happen in the default implementation but may be the
     source of security pitfalls. See the "Mixing magic link and password registration" section of
     `mix help phx.gen.auth`.
  """
  def login_user_by_magic_link(token) do
    case UserToken.verify_magic_link_token_query(token) do
      {:ok, query} ->
        case Repo.one(query) do
          # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
          {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
            raise """
            magic link log in is not allowed for unconfirmed users with a password set!

            This cannot happen with the default implementation, which indicates that you
            might have adapted the code to a different use case. Please make sure to read the
            "Mixing magic link and password registration" section of `mix help phx.gen.auth`.
            """

          {%User{confirmed_at: nil} = user, _token} ->
            user
            |> User.confirm_changeset()
            |> update_user_and_delete_all_tokens()

          {user, token} ->
            Repo.delete!(token)
            {:ok, {user, []}}

          nil ->
            {:error, :not_found}
        end

      :error ->
        {:error, :not_found}
    end
  end

  @doc ~S"""
  Delivers the update email instructions to the given user.

  ## Examples

      iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
      {:ok, %{to: ..., body: ...}}

  """
  def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
      when is_function(update_email_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")

    Repo.insert!(user_token)
    UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
  end

  @doc """
  Delivers the magic link login instructions to the given user.
  """
  def deliver_login_instructions(%User{} = user, magic_link_url_fun) when is_function(magic_link_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "login")
    Repo.insert!(user_token)
    UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
  end

  @doc """
  Deletes the signed token with the given context.
  """
  def delete_user_session_token(token) do
    Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
    :ok
  end

  ## Token helper

  defp update_user_and_delete_all_tokens(changeset) do
    Repo.transact(fn ->
      with {:ok, user} <- Repo.update(changeset) do
        tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)

        Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))

        {:ok, {user, tokens_to_expire}}
      end
    end)
  end

  ## User Consent

  @doc """
  Grants consent for a user.

  ## Examples

      iex> grant_consent(user_id, "privacy_policy")
      {:ok, %UserConsent{}}

      iex> grant_consent(user_id, "invalid_type")
      {:error, %Ecto.Changeset{}}
  """
  def grant_consent(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
    version = UserConsent.current_version(consent_type)
    granted_at = DateTime.utc_now(:second)

    %UserConsent{}
    |> UserConsent.grant_changeset(%{
      user_id: user_id,
      consent_type: consent_type,
      version: version,
      granted_at: granted_at
    })
    |> Repo.insert()
  end

  @doc """
  Revokes consent for a user.

  ## Examples

      iex> revoke_consent(consent_id)
      {:ok, %UserConsent{}}

      iex> revoke_consent("invalid")
      {:error, :not_found}
  """
  def revoke_consent(consent_id) when is_binary(consent_id) do
    case Repo.get(UserConsent, consent_id) do
      nil ->
        {:error, :not_found}

      consent ->
        consent
        |> UserConsent.revoke_changeset(%{revoked_at: DateTime.utc_now(:second)})
        |> Repo.update()
    end
  end

  @doc """
  Lists all consents for a user.

  ## Examples

      iex> list_user_consents(user_id)
      [%UserConsent{}, ...]
  """
  def list_user_consents(user_id) when is_binary(user_id) do
    UserConsent
    |> where([c], c.user_id == ^user_id)
    |> order_by([c], desc: c.granted_at)
    |> Repo.all()
  end

  @doc """
  Gets the active consent for a user and consent type.

  Returns the most recent non-revoked consent for the given type.

  ## Examples

      iex> get_active_consent(user_id, "privacy_policy")
      %UserConsent{}

      iex> get_active_consent(user_id, "terms_of_service")
      nil
  """
  def get_active_consent(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
    UserConsent
    |> where([c], c.user_id == ^user_id)
    |> where([c], c.consent_type == ^consent_type)
    |> where([c], is_nil(c.revoked_at))
    |> order_by([c], desc: c.granted_at)
    |> limit(1)
    |> Repo.one()
  end

  @doc """
  Checks if a user has granted consent for a specific type.

  ## Examples

      iex> has_consent?(user_id, "privacy_policy")
      true

      iex> has_consent?(user_id, "terms_of_service")
      false
  """
  def has_consent?(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
    get_active_consent(user_id, consent_type) != nil
  end

  @doc """
  Checks if a user has granted all required consents.

  ## Examples

      iex> has_all_required_consents?(user_id)
      true
  """
  def has_all_required_consents?(user_id) when is_binary(user_id) do
    Enum.all?(UserConsent.consent_types(), fn consent_type ->
      has_consent?(user_id, consent_type)
    end)
  end

  ## Policy Version Management

  @doc """
  Creates a new policy version.

  ## Examples

      iex> create_policy_version(%{policy_type: "privacy_policy", version: "2.0", ...})
      {:ok, %PolicyVersion{}}

      iex> create_policy_version(%{policy_type: "invalid", ...})
      {:error, %Ecto.Changeset{}}
  """
  def create_policy_version(attrs) do
    %PolicyVersion{}
    |> PolicyVersion.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Gets the current (most recent) version of a policy.

  ## Examples

      iex> get_current_policy_version("privacy_policy")
      %PolicyVersion{}

      iex> get_current_policy_version("terms_of_service")
      nil
  """
  def get_current_policy_version(policy_type) when is_binary(policy_type) do
    PolicyVersion
    |> where([p], p.policy_type == ^policy_type)
    |> where([p], p.effective_date <= ^DateTime.utc_now(:second))
    |> order_by([p], desc: p.effective_date)
    |> limit(1)
    |> Repo.one()
  end

  @doc """
  Gets a specific version of a policy.

  ## Examples

      iex> get_policy_version("privacy_policy", "1.0")
      %PolicyVersion{}

      iex> get_policy_version("privacy_policy", "999.0")
      nil
  """
  def get_policy_version(policy_type, version) when is_binary(policy_type) and is_binary(version) do
    PolicyVersion
    |> where([p], p.policy_type == ^policy_type)
    |> where([p], p.version == ^version)
    |> Repo.one()
  end

  @doc """
  Lists all versions of a policy.

  ## Examples

      iex> list_policy_versions("privacy_policy")
      [%PolicyVersion{}, ...]
  """
  def list_policy_versions(policy_type) when is_binary(policy_type) do
    PolicyVersion
    |> where([p], p.policy_type == ^policy_type)
    |> order_by([p], desc: p.effective_date)
    |> Repo.all()
  end

  @doc """
  Checks if a user needs to re-consent to a policy.

  Returns true if the current policy version is newer than the user's consent.

  ## Examples

      iex> needs_reconsent?(user_id, "privacy_policy")
      false

      iex> needs_reconsent?(user_id, "terms_of_service")
      true
  """
  def needs_reconsent?(user_id, policy_type) when is_binary(user_id) and is_binary(policy_type) do
    current_policy = get_current_policy_version(policy_type)
    user_consent = get_active_consent(user_id, policy_type)

    cond do
      is_nil(current_policy) -> false
      is_nil(user_consent) -> true
      user_consent.version != current_policy.version -> true
      true -> false
    end
  end

  @doc """
  Checks if a user needs to re-consent to any policies.

  Returns a list of policy types that require re-consent.

  ## Examples

      iex> policies_needing_reconsent(user_id)
      []

      iex> policies_needing_reconsent(user_id)
      ["privacy_policy", "terms_of_service"]
  """
  def policies_needing_reconsent(user_id) when is_binary(user_id) do
    Enum.filter(UserConsent.consent_types(), fn policy_type ->
      needs_reconsent?(user_id, policy_type)
    end)
  end

  ## Login Attempts

  @doc """
  Records a login attempt with GeoIP enrichment.

  ## Examples

      iex> record_login_attempt(%{
      ...>   user_id: "123",
      ...>   email: "user@example.com",
      ...>   success: true,
      ...>   method: "password",
      ...>   ip_address: "192.168.1.1"
      ...> })
      {:ok, %LoginAttempt{}}
  """
  def record_login_attempt(attrs) do
    # Enrich with GeoIP data if IP address is present
    attrs_with_location =
      case Map.get(attrs, :ip_address) do
        nil ->
          attrs

        ip_address ->
          case GeoIP.lookup_full(ip_address) do
            %{} = location ->
              Map.merge(attrs, %{
                country_code: location.country_code,
                country_name: location.country_name,
                city_name: location.city_name,
                subdivision_1_name: location.subdivision_1_name
              })

            nil ->
              attrs
          end
      end

    %LoginAttempt{}
    |> LoginAttempt.changeset(attrs_with_location)
    |> Repo.insert()
  end

  @doc """
  Lists login history for a user with pagination.

  ## Options

  - `:limit` - Maximum number of records to return (default: 50)
  - `:offset` - Number of records to skip (default: 0)
  - `:success` - Filter by success status (true/false, optional)

  ## Examples

      iex> list_user_login_history(user_id)
      [%LoginAttempt{}, ...]

      iex> list_user_login_history(user_id, limit: 20, success: false)
      [%LoginAttempt{}, ...]
  """
  def list_user_login_history(user_id, opts \\ []) do
    limit = Keyword.get(opts, :limit, 50)
    offset = Keyword.get(opts, :offset, 0)
    success_filter = Keyword.get(opts, :success)

    query =
      LoginAttempt
      |> where([la], la.user_id == ^user_id)
      |> order_by([la], desc: la.inserted_at)
      |> limit(^limit)
      |> offset(^offset)

    query =
      if is_boolean(success_filter) do
        where(query, [la], la.success == ^success_filter)
      else
        query
      end

    Repo.all(query)
  end

  @doc """
  Counts login attempts for a user.

  ## Options

  - `:success` - Filter by success status (true/false, optional)
  - `:since` - Only count attempts after this datetime (optional)

  ## Examples

      iex> count_user_login_attempts(user_id)
      42

      iex> count_user_login_attempts(user_id, success: false, since: ~U[2026-01-29 00:00:00Z])
      3
  """
  def count_user_login_attempts(user_id, opts \\ []) do
    success_filter = Keyword.get(opts, :success)
    since = Keyword.get(opts, :since)

    query = where(LoginAttempt, [la], la.user_id == ^user_id)

    query =
      if is_boolean(success_filter) do
        where(query, [la], la.success == ^success_filter)
      else
        query
      end

    query =
      if since do
        where(query, [la], la.inserted_at >= ^since)
      else
        query
      end

    Repo.aggregate(query, :count, :id)
  end

  @doc """
  Anonymizes all login attempts for a user (GDPR compliance).

  Sets user_id to NULL and records anonymized_at timestamp.
  IP addresses, locations, and other metadata are preserved for security analysis.

  ## Examples

      iex> anonymize_user_login_history(user_id)
      {5, nil}
  """
  def anonymize_user_login_history(user_id) do
    now = DateTime.utc_now()

    LoginAttempt
    |> where([la], la.user_id == ^user_id)
    |> where([la], is_nil(la.anonymized_at))
    |> Repo.update_all(set: [user_id: nil, anonymized_at: now])
  end

  ## Browser Sessions

  @doc """
  Creates a browser session with metadata from conn.

  Parses User-Agent header and enriches with GeoIP data.

  ## Required attributes

  - `:user_id` - User who owns the session
  - `:user_token_id` - Associated authentication token ID
  - `:ip_address` - IP address of the session
  - `:user_agent` - User-Agent header string
  - `:last_activity_at` - Initial activity timestamp
  - `:expires_at` - Session expiration timestamp

  ## Examples

      iex> create_browser_session(%{
      ...>   user_id: "123",
      ...>   user_token_id: "456",
      ...>   ip_address: "192.168.1.1",
      ...>   user_agent: "Mozilla/5.0...",
      ...>   last_activity_at: ~U[2026-01-29 19:00:00Z],
      ...>   expires_at: ~U[2026-02-12 19:00:00Z]
      ...> })
      {:ok, %BrowserSession{}}
  """
  def create_browser_session(attrs) do
    # Parse User-Agent for device metadata
    user_agent = Map.get(attrs, :user_agent)
    device_info = UserAgentParser.parse(user_agent)

    # Enrich with GeoIP data
    ip_address = Map.get(attrs, :ip_address)

    location =
      case GeoIP.lookup_full(ip_address) do
        %{} = loc ->
          %{
            country_code: loc.country_code,
            country_name: loc.country_name,
            city_name: loc.city_name,
            subdivision_1_name: loc.subdivision_1_name
          }

        nil ->
          %{}
      end

    # Merge all metadata
    attrs_with_metadata =
      attrs
      |> Map.merge(device_info)
      |> Map.merge(location)

    %BrowserSession{}
    |> BrowserSession.create_changeset(attrs_with_metadata)
    |> Repo.insert()
  end

  @doc """
  Lists active (non-expired) browser sessions for a user.

  Sessions are ordered by last_activity_at descending (most recent first).

  ## Examples

      iex> list_active_browser_sessions(user_id)
      [%BrowserSession{}, ...]
  """
  def list_active_browser_sessions(user_id) do
    now = DateTime.utc_now()

    BrowserSession
    |> where([bs], bs.user_id == ^user_id)
    |> where([bs], bs.expires_at > ^now)
    |> where([bs], is_nil(bs.anonymized_at))
    |> order_by([bs], desc: bs.last_activity_at)
    |> Repo.all()
  end

  @doc """
  Gets a browser session by user_token_id.

  ## Examples

      iex> get_browser_session_by_token(token_id)
      %BrowserSession{}

      iex> get_browser_session_by_token(invalid_id)
      nil
  """
  def get_browser_session_by_token(user_token_id) do
    Repo.get_by(BrowserSession, user_token_id: user_token_id)
  end

  @doc """
  Gets a browser session by the token value (binary).

  This is used by the UpdateSessionActivity plug to find the session
  associated with the current user's token cookie.

  ## Examples

      iex> get_browser_session_by_token_value(<<binary>>)
      %BrowserSession{}

      iex> get_browser_session_by_token_value(invalid_token)
      nil
  """
  def get_browser_session_by_token_value(token) when is_binary(token) do
    hashed_token = :crypto.hash(:sha256, token)

    user_token =
      UserToken
      |> where([ut], ut.token == ^hashed_token)
      |> where([ut], ut.context == "session")
      |> Repo.one()

    case user_token do
      nil -> nil
      token -> get_browser_session_by_token(token.id)
    end
  end

  @doc """
  Updates the last_activity_at timestamp for a browser session.

  ## Examples

      iex> touch_browser_session(session)
      {:ok, %BrowserSession{}}
  """
  def touch_browser_session(%BrowserSession{} = session) do
    session
    |> BrowserSession.touch_changeset()
    |> Repo.update()
  end

  @doc """
  Revokes a browser session (prevents self-revoke).

  Deletes both the BrowserSession and the associated UserToken.

  Returns `{:error, :self_revoke}` if attempting to revoke current session.

  ## Examples

      iex> revoke_browser_session(session_id, current_user_id)
      {:ok, %BrowserSession{}}

      iex> revoke_browser_session(current_session_id, current_user_id)
      {:error, :self_revoke}
  """
  def revoke_browser_session(session_id, _user_id, current_token_id) do
    session = Repo.get(BrowserSession, session_id)

    cond do
      is_nil(session) ->
        {:error, :not_found}

      session.user_token_id == current_token_id ->
        {:error, :self_revoke}

      true ->
        # Delete the user token (this will cascade delete the browser session)
        user_token = Repo.get(UserToken, session.user_token_id)

        if user_token do
          Repo.delete(user_token)
        else
          {:error, :not_found}
        end
    end
  end

  @doc """
  Revokes all browser sessions for a user except the current one.

  ## Examples

      iex> revoke_all_other_sessions(user_id, current_token_id)
      {3, nil}
  """
  def revoke_all_other_sessions(user_id, current_token_id) do
    # Get all sessions except current
    session_ids =
      BrowserSession
      |> where([bs], bs.user_id == ^user_id)
      |> where([bs], bs.user_token_id != ^current_token_id)
      |> select([bs], bs.user_token_id)
      |> Repo.all()

    # Delete all associated tokens (cascades to sessions)
    UserToken
    |> where([ut], ut.id in ^session_ids)
    |> Repo.delete_all()
  end

  @doc """
  Anonymizes all browser sessions for a user (GDPR compliance).

  Sets user_id to NULL and records anonymized_at timestamp.
  Device metadata and location data are preserved for security analysis.

  ## Examples

      iex> anonymize_user_browser_sessions(user_id)
      {2, nil}
  """
  def anonymize_user_browser_sessions(user_id) do
    now = DateTime.utc_now()

    BrowserSession
    |> where([bs], bs.user_id == ^user_id)
    |> where([bs], is_nil(bs.anonymized_at))
    |> Repo.update_all(set: [user_id: nil, anonymized_at: now])
  end

  @doc """
  Deletes expired browser sessions.

  Returns the number of sessions deleted.

  ## Examples

      iex> delete_expired_browser_sessions()
      5
  """
  def delete_expired_browser_sessions do
    now = DateTime.utc_now()

    {count, _} =
      BrowserSession
      |> where([bs], bs.expires_at < ^now)
      |> Repo.delete_all()

    count
  end
end
