defmodule Qtrace do
  @moduledoc """
  Quick function latency analysis and visualization based on Erlang tracing.

  `Qtrace` traces function calls and collects latency measurements. Uses
  histogram-based aggregation to provide quantile analysis (P50, P95, P99)
  and terminal-based visualization of performance distributions.

  ## Usage

      session = Qtrace.start_session(:all)
      Qtrace.trace_function(session, MyApp, :my_function, 2)
      Qtrace.trace_function(session, MyApp, :another_function, 1)

      # ... run your code ...

      Qtrace.plot_all_histograms(session)

      # Getting p10
      Qtrace.get_quantile(session, 0.10, MyApp, :my_function, 2)

  ## Example output

  ```txt
  Latency Histogram: Qtrace.Demo.web_request/0
  ================================================================================
              0              13             26             39             52
        5.9ms  │███ (3)
        6.9ms  │█ (1)
        8.1ms  │██ (2)
        10.3ms │█ (1)
        11.2ms │██████ (5)
        12.1ms │█████ (4)
        13.2ms │███ (3)
        14.2ms │███████ (6)
        15.4ms │████████████████ (14)
        16.7ms │█████████████████ (15)
        18.1ms │████████████████████ (17)
        19.6ms │█████████████████████████████████ (29)
        21.3ms │██████████████████████████████████████████████████████████ (50)
        23.0ms │██████████████████████████████████████████ (36)
        24.9ms │███████████████████████████████████████████████ (41)
        27.0ms │████████████████████████████████████████████████████████████ (52)
        29.3ms │█████████████████████████████████████████████████████████ (49)
        31.7ms │███████████████████████████████████████████████████████████ (51)
        34.4ms │██████████████████████████████████████ (33)
        37.2ms │████████████████████████████████████████ (35)
        40.3ms │████████████████████████ (21)
        43.7ms │███████ (6)
        47.3ms │███ (3)
        51.3ms │██ (2)
      134.0ms  │██ (2)
      157.2ms  │█ (1)
      199.9ms  │█ (1)
      216.6ms  │█████████ (8)
      234.6ms  │██ (2)
      254.2ms  │██ (2)
      275.4ms  │███ (3)
      298.3ms  │██ (2)
      323.2ms  │██ (2)
               └────────────────────────────────────────────────────────────
              0              13             26             39             52
  Stats:
    Min: 5.9ms
    Max: 323.2ms
    Samples: 502
    P50: 27.0ms
    P95: 47.3ms
    P99: 275.4ms
  ```

  ## Session Management

  Tracing is automatically stopped when the trace session gets garbage collected.
  The trace process may live for a bit longer: it lives for up to two minutes of
  inactivity.

  You can also stop sessions or clean up trace data manually:

      Qtrace.stop_session(session) # Stop the trace session
      Qtrace.cleanup(session)      # Stop the tracer process that stores the results

  Notice that, in some situations, **you may need to ensure that the session doesn't
  get garbage collected** to avoid the trace stopping unexpectedly.

  ## Notes

  - Sessions auto-stop when querying results
  - Measurements are in microseconds
  - Buckets have an error of up to 4%
  """

  alias Qtrace.Histogram
  alias Qtrace.Tracer
  alias Qtrace.Session

  @untraceable_modules [
    :tracer,
    Qtrace,
    Qtrace.Session,
    Qtrace.Tracer,
    DogSketch.SimpleDog
  ]

  require Logger

  @doc """
  Starts a new tracing session.

  ## Parameters

  - `processes` - Which processes to trace (`:all`, `:existing`, `:new`, or a `pid()`)

  ## Returns

  Returns a `Qtrace.Session.t()` struct containing the session details. If this struct
  is garbage collected, the trace stops.

  ## Examples

      # Trace all processes
      session = Qtrace.start_session(:all)

      # Trace only existing processes with a custom name
      session = Qtrace.start_session(:existing, :my_trace_session)

      # Trace a specific process
      session = Qtrace.start_session(self())

  """
  @spec start_session(:all | :existing | :new | pid()) :: Session.t()
  def start_session(processes) do
    :ok = validate_processes!(processes)

    {:ok, tracer} = Tracer.start_supervised([])

    ts = :trace.session_create(Qtrace.DefaultSession, tracer, [])

    :trace.process(ts, processes, true, [:call, :monotonic_timestamp])

    %Session{name: Qtrace.DefaultSession, trace_session: ts, pid: tracer}
  end

  @doc """
  Adds processes to an existing tracing session.

  ## Example

      session = Qtrace.start_session(:existing)
      Qtrace.add_processes(session, :new)  # Now traces existing + new processes

  """
  @spec add_processes(Session.t(), :all | :existing | :new | pid()) :: non_neg_integer()
  def add_processes(%Session{trace_session: ts}, processes) do
    :ok = validate_processes!(processes)
    :trace.process(ts, processes, true, [:call, :monotonic_timestamp])
  end

  @doc """
  Removes processes from an existing tracing session.

  ## Example

      session = Qtrace.start_session(:all)
      Qtrace.remove_processes(session, :new)  # Stop tracing new processes

  """
  @spec remove_processes(Session.t(), :all | :existing | :new | pid()) :: non_neg_integer()
  def remove_processes(%Session{trace_session: ts}, processes) do
    :ok = validate_processes!(processes)
    :trace.process(ts, processes, false, [:call, :monotonic_timestamp])
  end

  @doc """
  Enables tracing for a specific function.

  ## Example

      session = Qtrace.start_session(:all)
      Qtrace.trace_function(session, Enum, :map, 2)

      # Now all calls to Enum.map/2 will be traced

  ## Restrictions

  Some modules cannot be traced:
  - `:tracer`
  - `Qtrace` and its submodules
  - `DogSketch.SimpleDog`
  """
  @spec trace_function(Session.t(), module(), atom(), non_neg_integer()) :: non_neg_integer()
  def trace_function(%Session{trace_session: ts}, module, function, arity) do
    Code.ensure_loaded!(module)

    if module in @untraceable_modules do
      raise ArgumentError, """
      module #{inspect(module)} can't be traced by Qtrace

      Qtrace can't trace functions that it calls itself, otherwise we could get a trace loop\
      """
    end

    :trace.function(
      ts,
      {module, function, arity},
      [{:_, [], [{:return_trace}]}],
      [:local]
    )
  end

  @doc """
  Gets a quantile from traced function performance data. Returns latency in microseconds.

  ## Examples

      # Get median latency (P50)
      p50 = Qtrace.get_quantile(session, 0.5, MyApp, :my_function, 1)

      # Get 95th percentile
      p95 = Qtrace.get_quantile(session, 0.95, MyApp, :my_function, 1)
  """
  @spec get_quantile(
          Session.t(),
          quantile :: float(),
          module(),
          function_name :: atom(),
          arity()
        ) ::
          number() | nil
  def get_quantile(session, quantile, module, function, arity) do
    auto_destroy_session(session)

    if quantile <= 0 or quantile >= 1 do
      raise ArgumentError, "quantile must be between 0 and 1, got #{quantile}"
    end

    Tracer.get_quantile(session.pid, quantile, module, function, arity)
  end

  @doc """
  Builds histogram data from traced function measurements.

  Returns a `%Qtrace.Histogram{}` struct or `nil` if no data available.
  """
  @spec get_histogram(Session.t(), module(), atom(), non_neg_integer()) ::
          Qtrace.Histogram.t() | nil
  def get_histogram(session, module, function, arity) do
    auto_destroy_session(session)

    case Tracer.get_sketch(session.pid, module, function, arity) do
      nil -> nil
      sketch -> Histogram.build_histogram_from_sketch(sketch, module, function, arity)
    end
  end

  @doc """
  Displays a histogram of latency measurements in the terminal.

  ### Options

  - `:width` - Width of the histogram bars (default: 60)

  ## Example

      Qtrace.plot_histogram(session, MyApp, :my_function, 1)

  """
  @spec plot_histogram(Session.t(), module(), atom(), non_neg_integer(), keyword()) :: :ok
  def plot_histogram(session, module, function, arity, opts \\ []) do
    auto_destroy_session(session)

    case get_histogram(session, module, function, arity) do
      nil ->
        IO.puts("No data available for #{inspect(module)}.#{function}/#{arity}")

      histogram ->
        Qtrace.Plotter.plot_histogram(histogram, opts)
    end
  end

  @doc """
  Displays histograms for all traced functions.

  ### Options

  - `:width` - Width of the histogram bars (default: 60)
  """
  @spec plot_all_histograms(Session.t(), keyword()) :: :ok
  def plot_all_histograms(session, opts \\ []) do
    auto_destroy_session(session)

    case Tracer.list_functions(session.pid) do
      [] ->
        IO.puts("No traced functions found")
        :ok

      functions ->
        Enum.each(functions, fn {module, function, arity} ->
          plot_histogram(session, module, function, arity, opts)
        end)
    end
  end

  @doc """
  Stops a tracing session. Trace results remain available until cleanup.

  ## Example

      session = Qtrace.start_session(:all)
      # ... do tracing work ...
      Qtrace.stop_session(session)

  """
  @spec stop_session(Session.t()) :: Session.t()
  def stop_session(%Session{} = session) do
    :trace.session_destroy(session.trace_session)
    session
  end

  @doc """
  Stops the tracer process for a session.

  ## Example

      session = Qtrace.start_session(:all)
      # ... do tracing work ...
      Qtrace.cleanup(session)  # Immediately stop the tracer

  """
  @spec cleanup(Session.t()) :: :ok
  def cleanup(%Session{} = session) do
    Tracer.stop(session.pid)
  end

  defp validate_processes!(:all), do: :ok
  defp validate_processes!(:existing), do: :ok
  defp validate_processes!(:new), do: :ok

  defp validate_processes!(pid) when is_pid(pid) do
    if node(pid) == Node.self() do
      :ok
    else
      raise "invalid process: #{inspect(pid)}, should be a local process"
    end
  end

  defp validate_processes!(other) do
    raise ArgumentError,
          "invalid processes #{inspect(other)}, should be either: :all, :existing, :new or a local pid"
  end

  defp auto_destroy_session(session) do
    if :trace.session_destroy(session.trace_session) do
      Logger.warning(
        "Stopped trace session #{inspect(session.name)}: can't query results with session active"
      )
    end
  end
end
