lib/time.ex

defmodule Untangle.Time do
  @moduledoc """
  Provides timing utilities for measuring function execution time.

  This module offers decorators for measuring and logging the execution time of functions:
  - `time()`: Basic measurement of single function calls
  - `time_process()`: Tracks count and total time across multiple calls
  - `time_tree()`: Captures timing across process trees
  """

  use Decorator.Define,
    time: 0,
    time_process: 0,
    time_tree: 0,
    time: 1,
    time_process: 1,
    time_tree: 1

  require Logger

  # Check at compile time if ProcessTree is available
  @disable? !Untangle.log_level?(:debug)
  @process_tree_available Code.ensure_loaded?(ProcessTree)

  @doc """
  Decorator that measures and logs execution time of functions.

  When applied to a function, this decorator will:
  - Measure the function's execution time if the logger level is set to `:debug` 
  - Log the execution time if it exceeds a configured threshold (default: 10,000 microseconds)
  - Return the function's result unchanged

  ## Configuration

  The minimum time threshold for logging can be configured in your application:

  ```elixir
  config :untangle, :time_slow_min, 10_000  # microseconds
  ```

  ## Examples

      defmodule Demo do
        use Untangle.Time
        
        @decorate time()
        def slow_function(ms) do
          Process.sleep(ms)
          :ok
        end
      end
  """
  def time(slow_ms \\ nil, fn_body, context) do
    # At compile time, check if we're in debug environment
    if @disable? do
      # Not in debug mode at compile time, just return original function
      quote do
        unquote(fn_body)
      end
    else
      # In debug mode or can't determine at compile time, include runtime check
      quote do
        if Untangle.log_level?(:debug) do
          start = :erlang.monotonic_time()
          result = unquote(fn_body)
          finish = :erlang.monotonic_time()

          time = :erlang.convert_time_unit(finish - start, :native, :microsecond)

          if time > (unquote(slow_ms) || Application.get_env(:untangle, :time_slow_min, 10_000)) do
            Logger.debug(
              "#{time / 1_000} ms to run #{unquote(context.module)}.#{unquote(context.name)}/#{unquote(context.arity)}"
            )
          end

          result
        else
          unquote(fn_body)
        end
      end
    end
  end

  @doc """
  Decorator that aggregates execution counts and times across multiple calls.

  When applied to a function, this decorator will:
  - Track each call in the process dictionary
  - Count the number of executions
  - Sum total execution time
  - Log detailed statistics when execution time exceeds threshold
  - Return the function's result unchanged

  ## Examples

      defmodule Demo do
        use Untangle.Time
        
        @decorate time_process()
        def repeated_function(ms) do
          Process.sleep(ms)
          :ok
        end
      end

  After multiple calls, logs will include call count and total time.
  """
  def time_process(slow_ms \\ nil, fn_body, context) do
    # At compile time, check if we're in debug environment
    if @disable? do
      # Not in debug mode at compile time, just return original function
      quote do
        unquote(fn_body)
      end
    else
      function_key = "#{context.module}.#{context.name}/#{context.arity}"

      quote do
        if Untangle.log_level?(:debug) do
          # Get current stats or initialize
          previous_data =
            Process.get({:untangle_time_process, unquote(Macro.escape(function_key))}, %{
              count: 0,
              time: 0
            })

          # Measure this execution
          start = :erlang.monotonic_time()
          result = unquote(fn_body)
          finish = :erlang.monotonic_time()

          time = :erlang.convert_time_unit(finish - start, :native, :microsecond)

          # Update accumulated data with this process's information
          current_data = %{
            count: previous_data.count + 1,
            time: previous_data.time + time
          }

          if time > (unquote(slow_ms) || Application.get_env(:untangle, :time_slow_min, 10_000)) do
            Logger.debug(
              "#{time / 1_000} ms to run #{unquote(Macro.escape(function_key))} " <>
                "(call ##{current_data.count}, total: #{current_data.time / 1_000} ms, avg: #{current_data.time / current_data.count / 1_000} ms)"
            )
          end

          # Store updated data
          Process.put({:untangle_time_process, unquote(Macro.escape(function_key))}, current_data)

          result
        else
          unquote(fn_body)
        end
      end
    end
  end

  @doc """
  Decorator that measures execution time across a process tree.

  When applied to a function, this decorator will:
  - Track execution time of the function in the current process and all processes it spawns
  - Log detailed timing statistics when execution completes
  - Return the function's result unchanged

  Requires the [ProcessTree](https://www.hex.pm/packages/process_tree) library to be added to your app's dependencies.

  ## Examples

      defmodule Demo do
        use Untangle.Time
        
        @decorate time_tree()
        def spawn_function(depth \\ 1) do
          Process.sleep(50) 
          if depth < 3 do
            Task.async(fn -> 
              Demo.spawn_function(depth + 1)
            end) 
            |> Task.await()
          end
          :ok
        end
      end
  """
  def time_tree(slow_ms \\ nil, fn_body, context) do
    # At compile time, check if we're in debug mode and ProcessTree is available
    cond do
      @disable? ->
        # Not in debug mode at compile time, just return original function
        quote do
          unquote(fn_body)
        end

      not @process_tree_available ->
        # ProcessTree not available at compile time, fall back to time_process
        time_process(fn_body, context)

      true ->
        # Both debug mode possible and ProcessTree available, include runtime check
        function_key = "#{context.module}.#{context.name}/#{context.arity}"

        quote do
          if Untangle.log_level?(:debug) do
            # Get accumulated time from parent processes, if any
            parent_data =
              ProcessTree.get({:untangle_time_tree, unquote(Macro.escape(function_key))},
                cache: false,
                default: %{count: 0, time: 0}
              )

            # TODO: at the moment this will only return the execution times from the current process or the first parent process that has recorded data, rather than the sum of the data from all parents

            # Execute function while tracking time
            start = :erlang.monotonic_time()
            result = unquote(fn_body)
            finish = :erlang.monotonic_time()

            # Calculate this process's execution time
            time = :erlang.convert_time_unit(finish - start, :native, :microsecond)

            # Update accumulated data with this process's information
            current_data = %{
              count: parent_data.count + 1,
              time: parent_data.time + time
            }

            # Log results including parent process data
            if time > (unquote(slow_ms) || Application.get_env(:untangle, :time_slow_min, 10_000)) do
              if parent_data.count > 0 do
                # Include parent process information if available
                Logger.debug(
                  "#{time / 1_000} ms to run #{unquote(Macro.escape(function_key))} " <>
                    "(#{current_data.count} executions in process tree, total time: #{current_data.time / 1_000} ms, avg: #{current_data.time / current_data.count / 1_000} ms)"
                )
              else
                # Just log this process's information
                Logger.debug("#{time / 1_000} ms to run #{unquote(Macro.escape(function_key))}")
              end
            end

            # Store this data in process dictionary for child processes
            Process.put({:untangle_time_tree, unquote(Macro.escape(function_key))}, current_data)

            result
          else
            unquote(fn_body)
          end
        end
    end
  end

  def disabled?, do: @disable?
end