defmodule Plug.Debugger do
  @moduledoc """
  A module (**not a plug**) for debugging in development.

  This module is commonly used within a `Plug.Builder` or a `Plug.Router`
  and it wraps the `call/2` function.

  Notice `Plug.Debugger` *does not* catch errors, as errors should still
  propagate so that the Elixir process finishes with the proper reason.
  This module does not perform any logging either, as all logging is done
  by the web server handler.

  **Note:** If this module is used with `Plug.ErrorHandler`, only one of
  them will effectively handle errors. For this reason, it is recommended
  that `Plug.Debugger` is used before `Plug.ErrorHandler` and only in
  particular environments, like `:dev`.

  In case of an error, the rendered page drops the `content-security-policy`
  header before rendering the error to ensure that the error is displayed
  correctly.

  ## Examples

      defmodule MyApp do
        use Plug.Builder

        if Mix.env == :dev do
          use Plug.Debugger, otp_app: :my_app
        end

        plug :boom

        def boom(conn, _) do
          # Error raised here will be caught and displayed in a debug page
          # complete with a stacktrace and other helpful info.
          raise "oops"
        end
      end

  ## Options

    * `:otp_app` - the OTP application that is using Plug. This option is used
      to filter stacktraces that belong only to the given application.
    * `:style` - custom styles (see below)
    * `:banner` - the optional MFA (`{module, function, args}`) which receives
      exception details and returns banner contents to appear at the top of
      the page. May be any string, including markup.

  ## Custom styles

  You may pass a `:style` option to customize the look of the HTML page.

      use Plug.Debugger, style:
        [primary: "#c0392b", logo: "data:image/png;base64,..."]

  The following keys are available:

    * `:primary` - primary color
    * `:accent` - accent color
    * `:logo` - logo URI, or `nil` to disable

  The `:logo` is preferred to be a base64-encoded data URI so not to make any
  external requests, though external URLs (eg, `https://...`) are supported.

  ## Custom Banners

  You may pass an MFA (`{module, function, args}`) to be invoked when an
  error is rendered which provides a custom banner at the top of the
  debugger page. The function receives the following arguments, with the
  passed `args` concatenated at the end:

      [conn, status, kind, reason, stacktrace]

  For example, the following `:banner` option:

      use Plug.Debugger, banner: {MyModule, :debug_banner, []}

  would invoke the function:

      MyModule.debug_banner(conn, status, kind, reason, stacktrace)

  ## Links to the text editor

  If a `PLUG_EDITOR` environment variable is set, `Plug.Debugger` will
  use it to generate links to your text editor. The variable should be
  set with `__FILE__` and `__LINE__` placeholders which will be correctly
  replaced. For example (with the [TextMate](http://macromates.com) editor):

      txmt://open/?url=file://__FILE__&line=__LINE__

  Or, using Visual Studio Code:

      vscode://file/__FILE__:__LINE__

  You can also use `__RELATIVEFILE__` if your project path is different from
  the running application. This is useful when working with Docker containers.

      vscode://file//path/to/your/project/__RELATIVEFILE__:__LINE__
  """

  @already_sent {:plug_conn, :sent}

  @default_style %{
    primary: "#4e2a8e",
    accent: "#607080",
    highlight: "#f0f4fa",
    red_highlight: "#ffe5e5",
    line_color: "#eee",
    text_color: "#203040",
    logo:
      "data:image/png;base64,[encoded data, 12724 characters]",
    monospace_font: "menlo, consolas, monospace",
    background: "white",
    top_background: "#f9f9fa"
  }

  @default_dark_style %{
    primary: "#9d86d2",
    accent: "#9aa5b1",
    highlight: "#2d333b",
    red_highlight: "#5c2626",
    line_color: "#404040",
    text_color: "#e5e5e5",
    background: "#1c1c1c",
    top_background: "#2a2a2a",
    logo:
      "data:image/png;base64,[encoded data, 36648 characters]"
  }

  @salt "plug-debugger-actions"

  import Plug.Conn
  require Logger

  @doc false
  defmacro __using__(opts) do
    quote do
      @plug_debugger unquote(opts)
      @before_compile Plug.Debugger
    end
  end

  @doc false
  defmacro __before_compile__(_) do
    quote location: :keep do
      defoverridable call: 2

      def call(conn, opts) do
        try do
          case conn do
            %Plug.Conn{path_info: ["__plug__", "debugger", "action"], method: "POST"} ->
              Plug.Debugger.run_action(conn)

            %Plug.Conn{} ->
              super(conn, opts)
          end
        rescue
          e in Plug.Conn.WrapperError ->
            %{conn: conn, kind: kind, reason: reason, stack: stack} = e
            Plug.Debugger.__catch__(conn, kind, reason, stack, @plug_debugger)
        catch
          kind, reason ->
            Plug.Debugger.__catch__(conn, kind, reason, __STACKTRACE__, @plug_debugger)
        end
      end
    end
  end

  @doc false
  def __catch__(conn, kind, reason, stack, opts) do
    reason = Exception.normalize(kind, reason, stack)
    status = status(kind, reason)

    receive do
      @already_sent ->
        send(self(), @already_sent)
        log(status, kind, reason, stack)
        :erlang.raise(kind, reason, stack)
    after
      0 ->
        render(conn, status, kind, reason, stack, opts)
        log(status, kind, reason, stack)
        :erlang.raise(kind, reason, stack)
    end
  end

  # We don't log status >= 500 because those are treated as errors and logged later.
  defp log(status, kind, reason, stack) when status < 500,
    do: Logger.debug(Exception.format(kind, reason, stack))

  defp log(_status, _kind, _reason, _stack), do: :ok

  ## Rendering

  require EEx

  html_template_path = "lib/plug/templates/debugger.html.eex"
  EEx.function_from_file(:defp, :template_html, html_template_path, [:assigns])

  markdown_template_path = "lib/plug/templates/debugger.md.eex"
  EEx.function_from_file(:defp, :template_markdown, markdown_template_path, [:assigns])

  # Made public with @doc false for testing.
  @doc false
  def render(conn, status, kind, reason, stack, opts) do
    session = maybe_fetch_session(conn)
    params = maybe_fetch_query_params(conn)
    {title, message} = info(kind, reason)
    html? = accepts_html?(get_req_header(conn, "accept"))

    assigns = [
      conn: conn,
      title: title,
      formatted: Exception.format(kind, reason, stack),
      session: session,
      params: params,
      full_version: not html?
    ]

    markdown = template_markdown(assigns)

    if html? do
      conn =
        conn
        |> put_resp_content_type("text/html")
        |> delete_resp_header("content-security-policy")

      actions = encoded_actions_for_exception(reason, conn)
      last_path = actions_redirect_path(conn)
      style = Enum.into(opts[:style] || [], @default_style)
      style = maybe_merge_dark_styles(style, @default_dark_style)
      banner = banner(conn, status, kind, reason, stack, opts)

      assigns =
        Keyword.merge(assigns,
          conn: conn,
          message: maybe_autolink(message),
          markdown: h(markdown),
          style: style,
          banner: banner,
          actions: actions,
          frames: frames(:html, stack, opts),
          last_path: last_path
        )

      send_resp(conn, status, template_html(assigns))
    else
      conn = put_resp_content_type(conn, "text/markdown")
      send_resp(conn, status, markdown)
    end
  end

  @doc false
  def run_action(%Plug.Conn{} = conn) do
    with %Plug.Conn{body_params: params} = conn <- fetch_body_params(conn),
         {:ok, {module, function, args}} <-
           Plug.Crypto.verify(conn.secret_key_base, @salt, params["encoded_handler"]) do
      apply(module, function, args)

      conn
      |> Plug.Conn.put_resp_header("location", params["last_path"] || "/")
      |> send_resp(302, "")
      |> halt()
    else
      _ -> raise "could not run Plug.Debugger action"
    end
  end

  @doc false
  def encoded_actions_for_exception(exception, conn) do
    if conn.secret_key_base do
      actions = Plug.Exception.actions(exception)

      Enum.map(actions, fn %{label: label, handler: handler} ->
        encoded_handler = Plug.Crypto.sign(conn.secret_key_base, @salt, handler)
        %{label: label, encoded_handler: encoded_handler}
      end)
    else
      []
    end
  end

  defp actions_redirect_path(%Plug.Conn{
         method: "GET",
         request_path: request_path,
         query_string: query_string
       }) do
    case query_string do
      "" -> request_path
      query_string -> "#{request_path}?#{query_string}"
    end
  end

  defp actions_redirect_path(conn) do
    case get_req_header(conn, "referer") do
      [referer] -> referer
      [] -> "/"
    end
  end

  defp accepts_html?(_accept_header = []), do: false

  defp accepts_html?(_accept_header = [header | _]),
    do: String.contains?(header, ["*/*", "text/*", "text/html"])

  defp maybe_fetch_session(conn) do
    if conn.private[:plug_session_fetch] do
      conn |> fetch_session(conn) |> get_session()
    end
  end

  defp maybe_fetch_query_params(conn) do
    fetch_query_params(conn).params
  rescue
    Plug.Conn.InvalidQueryError ->
      case conn.params do
        %Plug.Conn.Unfetched{} -> %{}
        params -> params
      end
  end

  @parsers_opts Plug.Parsers.init(parsers: [:urlencoded])
  defp fetch_body_params(conn), do: Plug.Parsers.call(conn, @parsers_opts)

  defp status(:error, error), do: Plug.Exception.status(error)
  defp status(_, _), do: 500

  defp info(:error, error), do: {inspect(error.__struct__), Exception.message(error)}
  defp info(:throw, thrown), do: {"unhandled throw", inspect(thrown)}
  defp info(:exit, reason), do: {"unhandled exit", Exception.format_exit(reason)}

  defp frames(renderer, stacktrace, opts) do
    app = opts[:otp_app]
    editor = System.get_env("PLUG_EDITOR")

    stacktrace
    |> Enum.map_reduce(0, &each_frame(&1, &2, renderer, app, editor))
    |> elem(0)
  end

  defp each_frame(entry, index, renderer, root, editor) do
    {module, info, location, app, fun, arity, args} = get_entry(entry)
    {file, line} = {to_string(location[:file] || "nofile"), location[:line]}

    doc = module && get_doc(module, fun, arity, app)
    clauses = module && get_clauses(renderer, module, fun, args)
    source = get_source(app, module, file)
    context = get_context(root, app)
    snippet = get_snippet(source, line)

    {%{
       app: app,
       info: info,
       file: file,
       line: line,
       context: context,
       snippet: snippet,
       index: index,
       doc: doc,
       clauses: clauses,
       args: args,
       link: editor && get_editor(source, file, line, editor)
     }, index + 1}
  end

  # From :elixir_compiler_*
  defp get_entry({module, :__MODULE__, 0, location}) do
    {module, inspect(module) <> " (module)", location, get_app(module), nil, nil, nil}
  end

  # From :elixir_compiler_*
  defp get_entry({_module, :__MODULE__, 1, location}) do
    {nil, "(module)", location, nil, nil, nil, nil}
  end

  # From :elixir_compiler_*
  defp get_entry({_module, :__FILE__, 1, location}) do
    {nil, "(file)", location, nil, nil, nil, nil}
  end

  defp get_entry({module, fun, args, location}) when is_list(args) do
    arity = length(args)
    formatted_mfa = Exception.format_mfa(module, fun, arity)
    {module, formatted_mfa, location, get_app(module), fun, arity, args}
  end

  defp get_entry({module, fun, arity, location}) do
    {module, Exception.format_mfa(module, fun, arity), location, get_app(module), fun, arity, nil}
  end

  defp get_entry({fun, arity, location}) do
    {nil, Exception.format_fa(fun, arity), location, nil, fun, arity, nil}
  end

  defp get_app(module) do
    case :application.get_application(module) do
      {:ok, app} -> app
      :undefined -> nil
    end
  end

  defp get_doc(module, fun, arity, app) do
    with true <- has_docs?(module, fun, arity),
         {:ok, vsn} <- :application.get_key(app, :vsn) do
      vsn = vsn |> List.to_string() |> String.split("-") |> hd()
      fun = fun |> Atom.to_string() |> URI.encode()
      "https://hexdocs.pm/#{app}/#{vsn}/#{inspect(module)}.html##{fun}/#{arity}"
    else
      _ -> nil
    end
  end

  defp has_docs?(module, name, arity) do
    case Code.fetch_docs(module) do
      {:docs_v1, _, _, _, module_doc, _, docs} when module_doc != :hidden ->
        Enum.any?(docs, has_doc_matcher?(name, arity))

      _ ->
        false
    end
  end

  defp has_doc_matcher?(name, arity) do
    &match?(
      {{kind, ^name, ^arity}, _, _, doc, _}
      when kind in [:function, :macro] and doc != :hidden and doc != :none,
      &1
    )
  end

  defp get_clauses(renderer, module, fun, args) do
    with true <- is_list(args),
         {:ok, kind, clauses} <- Exception.blame_mfa(module, fun, args) do
      top_10 =
        clauses
        |> Enum.take(10)
        |> Enum.map(fn {args, guards} ->
          args = Enum.map_join(args, ", ", &blame_match(renderer, &1))
          base = "#{kind} #{fun}(#{args})"
          Enum.reduce(guards, base, &"#{&2} when #{blame_clause(renderer, &1)}")
        end)

      {length(top_10), length(clauses), top_10}
    else
      _ -> nil
    end
  end

  defp blame_match(:html, %{match?: true, node: node}),
    do: ~s(<i class="green">) <> h(Macro.to_string(node)) <> "</i>"

  defp blame_match(:html, %{match?: false, node: node}),
    do: ~s(<i class="red">) <> h(Macro.to_string(node)) <> "</i>"

  defp blame_match(_md, %{node: node}),
    do: h(Macro.to_string(node))

  defp blame_clause(renderer, {op, _, [left, right]}),
    do: blame_clause(renderer, left) <> " #{op} " <> blame_clause(renderer, right)

  defp blame_clause(renderer, node), do: blame_match(renderer, node)

  defp get_context(app, app) when app != nil, do: :app
  defp get_context(_app1, _app2), do: :all

  defp get_source(app, module, file) do
    cond do
      File.regular?(file) ->
        file

      File.regular?("apps/#{app}/#{file}") ->
        "apps/#{app}/#{file}"

      source = module && Code.ensure_loaded?(module) && module.module_info(:compile)[:source] ->
        to_string(source)

      true ->
        file
    end
  end

  defp get_editor(source, file, line, editor) do
    editor
    |> :binary.replace("__FILE__", URI.encode(Path.expand(source)))
    |> :binary.replace("__RELATIVEFILE__", URI.encode(file))
    |> :binary.replace("__LINE__", to_string(line))
    |> h
  end

  @radius 5

  defp get_snippet(file, line) do
    if File.regular?(file) and is_integer(line) do
      to_discard = max(line - @radius - 1, 0)
      lines = File.stream!(file) |> Stream.take(line + 5) |> Stream.drop(to_discard)

      {first_five, lines} = Enum.split(lines, line - to_discard - 1)
      first_five = with_line_number(first_five, to_discard + 1, false)

      {center, last_five} = Enum.split(lines, 1)
      center = with_line_number(center, line, true)
      last_five = with_line_number(last_five, line + 1, false)

      first_five ++ center ++ last_five
    end
  end

  defp with_line_number(lines, initial, highlight) do
    lines
    |> Enum.map_reduce(initial, fn line, acc -> {{acc, line, highlight}, acc + 1} end)
    |> elem(0)
  end

  defp banner(conn, status, kind, reason, stack, opts) do
    case Keyword.fetch(opts, :banner) do
      {:ok, {mod, func, args}} ->
        apply(mod, func, [conn, status, kind, reason, stack] ++ args)

      {:ok, other} ->
        raise ArgumentError,
              "expected :banner to be an MFA ({module, func, args}), got: #{inspect(other)}"

      :error ->
        nil
    end
  end

  ## Helpers

  defp method(%Plug.Conn{method: method}), do: method

  defp url(%Plug.Conn{scheme: scheme, host: host, port: port} = conn),
    do: "#{scheme}://#{host}:#{port}#{conn.request_path}"

  defp h(string) do
    string |> to_string() |> Plug.HTML.html_escape()
  end

  defp maybe_merge_dark_styles(style, default_dark_style) when style != @default_style do
    if Map.has_key?(style, :dark) do
      # only merge default dark if the user also passed the dark key
      Map.update!(style, :dark, fn existing ->
        Map.merge(default_dark_style, Map.new(existing))
      end)
    else
      style
    end
  end

  defp maybe_merge_dark_styles(style, default_dark_style) do
    Map.put(style, :dark, default_dark_style)
  end

  defp maybe_autolink(message) do
    splitted =
      Regex.split(~r/`[A-Z][A-Za-z0-9_.]+\.[a-z][A-Za-z0-9_!?]*\/\d+`/, message,
        include_captures: true,
        trim: true
      )

    Enum.map(splitted, &maybe_format_function_reference/1)
    |> IO.iodata_to_binary()
  end

  defp maybe_format_function_reference("`" <> reference = text) do
    reference = String.trim_trailing(reference, "`")

    with {:ok, m, f, a} <- get_mfa(reference),
         url when is_binary(url) <- get_doc(m, f, a, Application.get_application(m)) do
      ~s[<a href="#{url}" target="_blank">`#{h(reference)}`</a>]
    else
      _ -> h(text)
    end
  end

  defp maybe_format_function_reference(text), do: h(text)

  def get_mfa(capture) do
    [function_path, arity] = String.split(capture, "/")
    {arity, ""} = Integer.parse(arity)
    parts = String.split(function_path, ".")
    {function_str, parts} = List.pop_at(parts, -1)
    module = Module.safe_concat(parts)
    function = String.to_existing_atom(function_str)
    {:ok, module, function, arity}
  rescue
    _ -> :error
  end
end
