defmodule ToweropsWeb.UserAuth do
  @moduledoc """
  Authentication plugs and helpers for user session management.

  Provides Plug functions for:
  - Fetching current user from session
  - Requiring authenticated users
  - Redirecting guests and authenticated users
  - Managing remember-me tokens
  - User session lifecycle (login, logout)
  """
  use ToweropsWeb, :verified_routes

  import Phoenix.Controller
  import Plug.Conn

  alias Phoenix.LiveView
  alias Towerops.Accounts
  alias Towerops.Accounts.Scope

  # Make the remember me cookie valid for 14 days. This should match
  # the session validity setting in UserToken.
  @max_cookie_age_in_days 14
  @remember_me_cookie "_towerops_web_user_remember_me"
  @remember_me_options [
    sign: true,
    max_age: @max_cookie_age_in_days * 24 * 60 * 60,
    same_site: "Towerops"
  ]

  # How old the session token should be before a new one is issued. When a request is made
  # with a session token older than this value, then a new session token will be created
  # and the session and remember-me cookies (if set) will be updated with the new token.
  # Lowering this value will result in more tokens being created by active users. Increasing
  # it will result in less time before a session token expires for a user to get issued a new
  # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
  # the reissuing of tokens completely.
  @session_reissue_age_in_days 7

  @doc """
  Logs the user in.

  Redirects to the session's `:user_return_to` path
  or falls back to the `signed_in_path/1`.
  """
  def log_in_user(conn, user, params \\ %{}) do
    user_return_to = get_session(conn, :user_return_to)

    conn
    |> create_or_extend_session(user, params)
    |> redirect(to: user_return_to || signed_in_path(conn))
  end

  @doc """
  Logs the user out.

  It clears all session data for safety. See renew_session.
  """
  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_user_session_token(user_token)

    _ =
      if live_socket_id = get_session(conn, :live_socket_id) do
        ToweropsWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
      end

    conn
    |> renew_session(nil)
    |> delete_resp_cookie(@remember_me_cookie)
    |> redirect(to: ~p"/users/log-in")
  end

  @doc """
  Authenticates the user by looking into the session and remember me token.

  Will reissue the session token if it is older than the configured age.
  Handles impersonation by checking session state.
  """
  def fetch_current_scope_for_user(conn, _opts) do
    # Check if currently impersonating
    if get_session(conn, :impersonating) do
      superuser_id = get_session(conn, :superuser_id)
      target_user_id = get_session(conn, :target_user_id)

      # Validate we have both IDs before attempting to fetch users
      if superuser_id && target_user_id do
        with superuser when not is_nil(superuser) <- Accounts.get_user(superuser_id),
             target_user when not is_nil(target_user) <- Accounts.get_user(target_user_id) do
          assign(conn, :current_scope, Scope.for_impersonation(superuser, target_user))
        else
          _ ->
            # Impersonation invalid, clear it
            conn
            |> delete_session(:superuser_id)
            |> delete_session(:target_user_id)
            |> delete_session(:impersonating)
            |> assign(:current_scope, Scope.for_user(nil))
        end
      else
        # Missing IDs, clear invalid impersonation state
        conn
        |> delete_session(:superuser_id)
        |> delete_session(:target_user_id)
        |> delete_session(:impersonating)
        |> assign(:current_scope, Scope.for_user(nil))
      end
    else
      # Normal authentication flow
      with {token, conn} <- ensure_user_token(conn),
           {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
        conn
        |> assign(:current_scope, Scope.for_user(user))
        |> maybe_reissue_user_session_token(user, token_inserted_at)
      else
        nil -> assign(conn, :current_scope, Scope.for_user(nil))
      end
    end
  end

  defp ensure_user_token(conn) do
    if token = get_session(conn, :user_token) do
      {token, conn}
    else
      conn = fetch_cookies(conn, signed: [@remember_me_cookie])

      if token = conn.cookies[@remember_me_cookie] do
        {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
      end
    end
  end

  # Reissue the session token if it is older than the configured reissue age.
  defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
    token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)

    if token_age >= @session_reissue_age_in_days do
      create_or_extend_session(conn, user, %{})
    else
      conn
    end
  end

  # This function is the one responsible for creating session tokens
  # and storing them safely in the session and cookies. It may be called
  # either when logging in, during sudo mode, or to renew a session which
  # will soon expire.
  #
  # When the session is created, rather than extended, the renew_session
  # function will clear the session to avoid fixation attacks. See the
  # renew_session function to customize this behaviour.
  defp create_or_extend_session(conn, user, params) do
    token = Accounts.generate_user_session_token(user)
    remember_me = get_session(conn, :user_remember_me)

    conn
    |> renew_session(user)
    |> put_token_in_session(token)
    |> maybe_write_remember_me_cookie(token, params, remember_me)
  end

  # Do not renew session if the user is already logged in
  # to prevent CSRF errors or data being lost in tabs that are still open
  defp renew_session(conn, user)
       when is_map_key(conn.assigns, :current_scope) and not is_nil(conn.assigns.current_scope) and
              not is_nil(conn.assigns.current_scope.user) and conn.assigns.current_scope.user.id == user.id do
    conn
  end

  # This function renews the session ID and erases the whole
  # session to avoid fixation attacks. If there is any data
  # in the session you may want to preserve after log in/log out,
  # you must explicitly fetch the session data before clearing
  # and then immediately set it after clearing, for example:
  #
  #     defp renew_session(conn, _user) do
  #       delete_csrf_token()
  #       preferred_locale = get_session(conn, :preferred_locale)
  #
  #       conn
  #       |> configure_session(renew: true)
  #       |> clear_session()
  #       |> put_session(:preferred_locale, preferred_locale)
  #     end
  #
  defp renew_session(conn, _user) do
    delete_csrf_token()
    user_return_to = get_session(conn, :user_return_to)

    conn
    |> configure_session(renew: true)
    |> clear_session()
    |> maybe_restore_return_to(user_return_to)
  end

  defp maybe_restore_return_to(conn, nil), do: conn
  defp maybe_restore_return_to(conn, path), do: put_session(conn, :user_return_to, path)

  defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
    do: write_remember_me_cookie(conn, token)

  defp maybe_write_remember_me_cookie(conn, token, _params, true), do: write_remember_me_cookie(conn, token)

  defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn

  defp write_remember_me_cookie(conn, token) do
    conn
    |> put_session(:user_remember_me, true)
    |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
  end

  defp put_token_in_session(conn, token) do
    put_session(conn, :user_token, token)
  end

  @doc """
  Plug for routes that require sudo mode.
  """
  def require_sudo_mode(conn, _opts) do
    if Accounts.sudo_mode?(conn.assigns.current_scope.user, -720) do
      conn
    else
      conn
      |> put_flash(:error, "You must re-authenticate to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log-in")
      |> halt()
    end
  end

  @doc """
  Plug for routes that require the user to not be authenticated.
  """
  def redirect_if_user_is_authenticated(conn, _opts) do
    if conn.assigns.current_scope do
      conn
      |> redirect(to: signed_in_path(conn))
      |> halt()
    else
      conn
    end
  end

  defp signed_in_path(conn) do
    user = conn.assigns[:current_scope] && conn.assigns.current_scope.user

    if user do
      # Get user's organizations (ordered by most recently joined first)
      case Towerops.Organizations.list_user_organizations(user.id) do
        [_first_org | _] ->
          # Devices page (no longer needs org slug)
          ~p"/devices"

        [] ->
          # No organizations yet, go to org list
          ~p"/orgs"
      end
    else
      ~p"/orgs"
    end
  end

  @doc """
  Plug for routes that require the user to be authenticated.
  """
  def require_authenticated_user(conn, _opts) do
    if conn.assigns.current_scope && conn.assigns.current_scope.user do
      conn
    else
      conn
      |> put_flash(:error, "You must log in to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log-in")
      |> halt()
    end
  end

  @doc """
  Plug for routes that require the user to be a superuser.
  """
  def require_superuser(conn, _opts) do
    user = conn.assigns.current_scope && conn.assigns.current_scope.user

    if user && user.is_superuser do
      conn
    else
      conn
      |> put_flash(:error, "You must be a superuser to access this page.")
      |> redirect(to: ~p"/orgs")
      |> halt()
    end
  end

  defp maybe_store_return_to(%{method: "GET"} = conn) do
    put_session(conn, :user_return_to, current_path(conn))
  end

  defp maybe_store_return_to(conn), do: conn

  @doc """
  Plug to store the current path for LiveView routes.

  This runs in the pipeline before LiveView mount, allowing us to store
  the return path in the session before on_mount callbacks run. Only stores
  if there's no existing return path to avoid overwriting.
  """
  def store_return_to_for_liveview(conn, _opts) do
    # Only store for GET requests and only if there's no existing return path
    if conn.method == "GET" && is_nil(get_session(conn, :user_return_to)) do
      put_session(conn, :user_return_to, current_path(conn))
    else
      conn
    end
  end

  @doc """
  Plug to load the current organization from the URL slug.

  Expects the organization slug to be in path_params as "org_slug".
  Verifies that the current user is a member of the organization.
  """
  def load_current_organization(conn, _opts) do
    org_slug = conn.path_params["org_slug"]
    user = conn.assigns.current_scope && conn.assigns.current_scope.user

    if org_slug && user do
      organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
      membership = Towerops.Organizations.get_membership(organization.id, user.id)

      if membership do
        conn
        |> assign(:current_organization, organization)
        |> assign(:current_membership, membership)
      else
        conn
        |> put_flash(:error, "You don't have access to this organization.")
        |> redirect(to: ~p"/orgs")
        |> halt()
      end
    else
      conn
      |> put_flash(:error, "Organization not found.")
      |> redirect(to: ~p"/orgs")
      |> halt()
    end
  rescue
    Ecto.NoResultsError ->
      conn
      |> put_flash(:error, "Organization not found.")
      |> redirect(to: ~p"/orgs")
      |> halt()
  end

  @doc """
  Mounts authentication state for LiveView routes.

  ## Hooks

    * `:redirect_if_user_is_authenticated` - Redirects authenticated users
    * `:require_authenticated_user` - Requires user authentication
    * `:load_current_organization` - Loads organization from URL slug

  """
  def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
    socket = mount_current_scope(socket, session)

    if socket.assigns.current_scope && socket.assigns.current_scope.user do
      {:halt, LiveView.redirect(socket, to: signed_in_path(socket))}
    else
      {:cont, socket}
    end
  end

  def on_mount(:require_authenticated_user, _params, session, socket) do
    socket = mount_current_scope(socket, session)

    if socket.assigns.current_scope && socket.assigns.current_scope.user do
      {:cont, socket}
    else
      socket =
        socket
        |> LiveView.put_flash(:error, "You must log in to access this page.")
        |> LiveView.redirect(to: ~p"/users/log-in")

      {:halt, socket}
    end
  end

  def on_mount(:load_current_organization, %{"org_slug" => org_slug}, _session, socket) do
    user = socket.assigns.current_scope && socket.assigns.current_scope.user

    if org_slug && user do
      organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
      membership = Towerops.Organizations.get_membership(organization.id, user.id)

      if membership do
        {:cont,
         socket
         |> Phoenix.Component.assign(:current_organization, organization)
         |> Phoenix.Component.assign(:current_membership, membership)}
      else
        socket =
          socket
          |> LiveView.put_flash(:error, "You don't have access to this organization.")
          |> LiveView.redirect(to: ~p"/orgs")

        {:halt, socket}
      end
    else
      socket =
        socket
        |> LiveView.put_flash(:error, "Organization not found.")
        |> LiveView.redirect(to: ~p"/orgs")

      {:halt, socket}
    end
  rescue
    Ecto.NoResultsError ->
      socket =
        socket
        |> LiveView.put_flash(:error, "Organization not found.")
        |> LiveView.redirect(to: ~p"/orgs")

      {:halt, socket}
  end

  def on_mount(:load_current_organization, _params, _session, socket) do
    {:cont, socket}
  end

  def on_mount(:load_default_organization, _params, session, socket) do
    user = socket.assigns.current_scope && socket.assigns.current_scope.user

    if user do
      # Try to get organization from session, otherwise use first organization
      org_id = session["current_organization_id"]

      organization =
        if org_id do
          try do
            Towerops.Organizations.get_organization!(org_id)
          rescue
            Ecto.NoResultsError -> nil
          end
        else
          # Get first organization user has access to
          case Towerops.Organizations.list_user_organizations(user.id) do
            [first_org | _] -> first_org
            [] -> nil
          end
        end

      if organization do
        membership = Towerops.Organizations.get_membership(organization.id, user.id)

        if membership do
          {:cont,
           socket
           |> Phoenix.Component.assign(:current_organization, organization)
           |> Phoenix.Component.assign(:current_membership, membership)}
        else
          socket =
            socket
            |> LiveView.put_flash(:error, "You don't have access to any organizations.")
            |> LiveView.redirect(to: ~p"/orgs")

          {:halt, socket}
        end
      else
        socket =
          socket
          |> LiveView.put_flash(:error, "Please select an organization.")
          |> LiveView.redirect(to: ~p"/orgs")

        {:halt, socket}
      end
    else
      {:cont, socket}
    end
  end

  def on_mount(:require_superuser, _params, session, socket) do
    socket = mount_current_scope(socket, session)
    user = socket.assigns.current_scope && socket.assigns.current_scope.user

    if user && user.is_superuser do
      {:cont, socket}
    else
      socket =
        socket
        |> LiveView.put_flash(:error, "You must be a superuser to access this page.")
        |> LiveView.redirect(to: ~p"/orgs")

      {:halt, socket}
    end
  end

  def on_mount(:require_sudo_mode, _params, _session, socket) do
    user = socket.assigns.current_scope && socket.assigns.current_scope.user

    if user && Accounts.sudo_mode?(user, -720) do
      {:cont, socket}
    else
      socket =
        socket
        |> LiveView.put_flash(:error, "You must re-authenticate to access this page.")
        |> LiveView.redirect(to: ~p"/users/log-in")

      {:halt, socket}
    end
  end

  defp mount_current_scope(socket, session) do
    socket =
      Phoenix.Component.assign_new(socket, :current_scope, fn ->
        build_scope_from_session(session)
      end)

    # Get timezone from user profile, connect params, or default to UTC
    user = socket.assigns.current_scope && socket.assigns.current_scope.user
    connect_timezone = get_in(socket.private, [:connect_params, "timezone"])

    timezone =
      cond do
        user && user.timezone -> user.timezone
        connect_timezone -> connect_timezone
        true -> "UTC"
      end

    # If user is logged in but doesn't have a timezone set, update their profile
    if user && !user.timezone && connect_timezone do
      Task.start(fn ->
        Accounts.update_user_profile(user, %{timezone: connect_timezone})
      end)
    end

    Phoenix.Component.assign(socket, :timezone, timezone)
  end

  defp build_scope_from_session(session) do
    if session["impersonating"] do
      build_impersonation_scope(session)
    else
      build_normal_scope(session)
    end
  end

  defp build_impersonation_scope(session) do
    superuser_id = session["superuser_id"]
    target_user_id = session["target_user_id"]

    if superuser_id && target_user_id do
      fetch_impersonation_users(superuser_id, target_user_id)
    else
      Scope.for_user(nil)
    end
  end

  defp fetch_impersonation_users(superuser_id, target_user_id) do
    with superuser when not is_nil(superuser) <- Accounts.get_user(superuser_id),
         target_user when not is_nil(target_user) <- Accounts.get_user(target_user_id) do
      Scope.for_impersonation(superuser, target_user)
    else
      _ -> Scope.for_user(nil)
    end
  end

  defp build_normal_scope(session) do
    case session["user_token"] do
      nil -> Scope.for_user(nil)
      user_token -> fetch_user_from_token(user_token)
    end
  end

  defp fetch_user_from_token(user_token) do
    case Accounts.get_user_by_session_token(user_token) do
      {user, _token_inserted_at} -> Scope.for_user(user)
      nil -> Scope.for_user(nil)
    end
  end

  @doc """
  Start impersonating a user as a superuser.

  Stores the original superuser ID in the session and updates the current scope
  to show the impersonated user. Creates an audit log entry.
  """
  def start_impersonation(conn, target_user_id) do
    superuser = conn.assigns.current_scope.user
    target_user = Accounts.get_user!(target_user_id)

    # Prevent impersonating self or other superusers
    if target_user.id == superuser.id do
      conn
      |> put_flash(:error, "You cannot impersonate yourself.")
      |> redirect(to: ~p"/admin/users")
      |> halt()
    else
      if target_user.is_superuser do
        conn
        |> put_flash(:error, "You cannot impersonate other superusers.")
        |> redirect(to: ~p"/admin/users")
        |> halt()
      else
        # Create audit log
        ip = to_string(:inet_parse.ntoa(conn.remote_ip))

        Towerops.Admin.create_audit_log(%{
          action: "impersonate_start",
          superuser_id: superuser.id,
          target_user_id: target_user.id,
          metadata: %{target_email: target_user.email},
          ip_address: ip
        })

        # Store superuser ID and target user ID in session and update scope
        conn
        |> put_session(:superuser_id, superuser.id)
        |> put_session(:target_user_id, target_user.id)
        |> put_session(:impersonating, true)
        |> assign(:current_scope, Scope.for_impersonation(superuser, target_user))
        |> put_flash(:info, "Now impersonating #{target_user.email}")
        |> redirect(to: ~p"/orgs")
      end
    end
  end

  @doc """
  Stop impersonating and restore the superuser session.

  Removes impersonation state from the session and restores the original superuser
  scope. Creates an audit log entry.
  """
  def stop_impersonation(conn) do
    superuser_id = get_session(conn, :superuser_id)
    target_user = conn.assigns.current_scope.user
    superuser = Accounts.get_user!(superuser_id)

    # Create audit log
    ip = to_string(:inet_parse.ntoa(conn.remote_ip))

    Towerops.Admin.create_audit_log(%{
      action: "impersonate_end",
      superuser_id: superuser_id,
      target_user_id: target_user.id,
      metadata: %{target_email: target_user.email},
      ip_address: ip
    })

    # Devices page
    redirect_path =
      case Towerops.Organizations.list_user_organizations(superuser.id) do
        [_first_org | _] -> ~p"/devices"
        [] -> ~p"/orgs"
      end

    conn
    |> delete_session(:superuser_id)
    |> delete_session(:target_user_id)
    |> delete_session(:impersonating)
    |> assign(:current_scope, Scope.for_user(superuser))
    |> put_flash(:info, "Stopped impersonating")
    |> redirect(to: redirect_path)
  end
end
