Skip to main content

lib/jido/chat/github/adapter.ex

defmodule Jido.Chat.GitHub.Adapter do
  @moduledoc "GitHub Issues `Jido.Chat.Adapter` implementation."
  use Jido.Chat.Adapter

  alias Jido.Chat.{
    Attachment,
    Author,
    EventEnvelope,
    FileUpload,
    Incoming,
    Media,
    Message,
    MessagePage,
    PostPayload,
    ReactionEvent,
    Response,
    Thread,
    ThreadPage,
    ThreadSummary,
    WebhookRequest,
    WebhookResponse
  }

  alias Jido.Chat.GitHub.Transport.ReqClient

  @github_reactions %{
    "+1" => "+1",
    "thumbsup" => "+1",
    "thumbs_up" => "+1",
    <<0x1F44D::utf8>> => "+1",
    "-1" => "-1",
    "thumbsdown" => "-1",
    "thumbs_down" => "-1",
    <<0x1F44E::utf8>> => "-1",
    "laugh" => "laugh",
    "smile" => "laugh",
    <<0x1F604::utf8>> => "laugh",
    "confused" => "confused",
    <<0x1F615::utf8>> => "confused",
    "heart" => "heart",
    <<0x2764::utf8>> => "heart",
    <<0x2764::utf8, 0xFE0F::utf8>> => "heart",
    "hooray" => "hooray",
    "tada" => "hooray",
    <<0x1F389::utf8>> => "hooray",
    "rocket" => "rocket",
    <<0x1F680::utf8>> => "rocket",
    "eyes" => "eyes",
    <<0x1F440::utf8>> => "eyes"
  }

  @impl true
  def channel_type, do: :github

  @impl true
  def capabilities do
    %{
      send_message: :native,
      edit_message: :native,
      delete_message: :native,
      fetch_thread: :native,
      fetch_message: :native,
      fetch_messages: :native,
      list_threads: :native,
      open_thread: :native,
      post_message: :native,
      post_channel_message: :native,
      webhook: :native,
      verify_webhook: :native,
      parse_event: :native,
      format_webhook_response: :native,
      send_file: :native,
      start_typing: :unsupported,
      add_reaction: :native,
      remove_reaction: :native,
      post_ephemeral: :unsupported,
      open_modal: :unsupported,
      fetch_channel_messages: :native,
      markdown: :native,
      multi_file: :native
    }
  end

  @impl true
  def transform_incoming(
        %{"comment" => comment, "issue" => issue, "repository" => repo} = payload
      ) do
    {:ok, incoming_from_comment(comment, issue, repo, payload)}
  end

  def transform_incoming(%{"issue" => issue, "repository" => repo} = payload) do
    {:ok, incoming_from_issue(issue, repo, payload)}
  end

  def transform_incoming(_), do: {:error, :unsupported_payload}

  @impl true
  def send_message(room_id, text, opts \\ []) do
    with {:ok, target} <- parse_room_id(room_id),
         {:ok, body} <- render_text_body(text, opts),
         {:ok, raw} <-
           transport(opts).create_issue_comment(
             target.owner,
             target.repo,
             target.issue_number,
             body,
             opts
           ) do
      {:ok, response_from_comment(raw, room_id)}
    end
  end

  @impl true
  def post_message(room_id, payload, opts \\ [])

  def post_message(room_id, %PostPayload{} = payload, opts) do
    case parse_room_id(room_id) do
      {:ok, target} ->
        post_thread_payload(room_id, target, payload, opts)

      {:error, _reason} ->
        post_repo_payload(room_id, payload, opts)
    end
  end

  def post_message(room_id, payload, opts) when is_map(payload) do
    post_message(room_id, PostPayload.new(payload), opts)
  end

  @impl true
  def post_channel_message(repo_id, text, opts \\ []) do
    post_repo_payload(repo_id, PostPayload.text(text), opts)
  end

  @impl true
  def send_file(room_id, file, opts \\ []) do
    upload = FileUpload.normalize(file)
    caption = Keyword.get(opts, :caption) || Keyword.get(opts, :text)

    payload =
      PostPayload.new(%{
        kind: :text,
        text: caption,
        files: [upload],
        metadata: Keyword.get(opts, :metadata, %{})
      })

    post_message(room_id, payload, Keyword.drop(opts, [:caption, :text]))
  end

  @impl true
  def edit_message(room_id, comment_id, text, opts \\ []) do
    with {:ok, target} <- parse_room_id(room_id),
         {:ok, body} <- render_text_body(text, opts),
         {:ok, raw} <-
           transport(opts).update_issue_comment(target.owner, target.repo, comment_id, body, opts) do
      {:ok, response_from_comment(raw, room_id, :edited)}
    end
  end

  @impl true
  def delete_message(room_id, comment_id, opts \\ []) do
    with {:ok, target} <- parse_room_id(room_id) do
      transport(opts).delete_issue_comment(target.owner, target.repo, comment_id, opts)
    end
  end

  @impl true
  def fetch_thread(room_id, opts \\ []) do
    with {:ok, target} <- parse_room_id(room_id),
         {:ok, issue} <-
           transport(opts).get_issue(target.owner, target.repo, target.issue_number, opts) do
      {:ok, thread_from_issue(issue, target)}
    end
  end

  @impl true
  def open_thread(room_or_repo_id, issue_number, opts \\ []) do
    with {:ok, room_id} <- open_thread_room_id(room_or_repo_id, issue_number) do
      fetch_thread(room_id, opts)
    end
  end

  @impl true
  def fetch_message(room_id, comment_id, opts \\ []) do
    with {:ok, target} <- parse_room_id(room_id),
         {:ok, comment} <-
           transport(opts).get_issue_comment(target.owner, target.repo, comment_id, opts) do
      {:ok, message_from_comment(comment, room_id)}
    end
  end

  @impl true
  def fetch_messages(room_id, opts \\ []) do
    with {:ok, target} <- parse_room_id(room_id),
         {:ok, comments} <-
           transport(opts).list_issue_comments(
             target.owner,
             target.repo,
             target.issue_number,
             opts
           ) do
      {:ok,
       MessagePage.new(%{
         messages: Enum.map(comments, &message_from_comment(&1, room_id)),
         next_cursor: nil,
         metadata: %{"raw" => comments}
       })}
    end
  end

  @impl true
  def list_threads(repo_id, opts \\ []) do
    with {:ok, target} <- parse_repo_id(repo_id),
         {:ok, issues} <- transport(opts).list_issues(target.owner, target.repo, opts) do
      threads =
        issues
        |> Enum.reject(&pull_request_issue?/1)
        |> Enum.map(&thread_summary_from_issue(&1, target))

      {:ok,
       ThreadPage.new(%{
         threads: threads,
         next_cursor: nil,
         metadata: %{"raw" => issues}
       })}
    end
  end

  @impl true
  def fetch_channel_messages(repo_id, opts \\ []) do
    with {:ok, target} <- parse_repo_id(repo_id),
         {:ok, issues} <- transport(opts).list_issues(target.owner, target.repo, opts) do
      messages =
        issues
        |> Enum.reject(&pull_request_issue?/1)
        |> Enum.map(&issue_message_from_issue(&1, target))

      {:ok,
       MessagePage.new(%{
         messages: messages,
         next_cursor: nil,
         metadata: %{"raw" => issues}
       })}
    end
  end

  @impl true
  def add_reaction(room_id, message_id, emoji, opts \\ []) do
    with {:ok, target} <- parse_room_id(room_id),
         {:ok, content} <- github_reaction(emoji) do
      create_reaction(target, message_id, content, opts)
    end
  end

  defp create_reaction(target, message_id, content, opts) do
    case reaction_target(opts) do
      :issue ->
        transport(opts).create_issue_reaction(
          target.owner,
          target.repo,
          target.issue_number,
          content,
          opts
        )

      :comment ->
        transport(opts).create_issue_comment_reaction(
          target.owner,
          target.repo,
          message_id,
          content,
          opts
        )
    end
    |> case do
      {:ok, _reaction} -> :ok
      {:error, _reason} = error -> error
    end
  end

  @impl true
  def remove_reaction(room_id, message_id, emoji, opts \\ []) do
    with {:ok, target} <- parse_room_id(room_id),
         {:ok, content} <- github_reaction(emoji),
         {:ok, reaction_id} <- reaction_id(target, message_id, content, opts) do
      case reaction_target(opts) do
        :issue ->
          transport(opts).delete_issue_reaction(
            target.owner,
            target.repo,
            target.issue_number,
            reaction_id,
            opts
          )

        :comment ->
          transport(opts).delete_issue_comment_reaction(
            target.owner,
            target.repo,
            message_id,
            reaction_id,
            opts
          )
      end
    end
  end

  @impl true
  def handle_webhook(%Jido.Chat{} = chat, payload, opts \\ []) when is_map(payload) do
    request =
      WebhookRequest.new(%{
        adapter_name: :github,
        headers: opts[:headers] || %{},
        payload: payload,
        raw: opts[:raw_body] || opts[:raw] || payload,
        metadata: %{raw_body: opts[:raw_body] || opts[:raw]}
      })

    with :ok <- verify_webhook(request, opts),
         {:ok, parsed_event} <- parse_event(request, opts) do
      route_parsed_event(chat, parsed_event, opts, request)
    end
  end

  @impl true
  def verify_webhook(request, opts \\ [])

  def verify_webhook(%WebhookRequest{} = request, opts) do
    secret = Keyword.get(opts, :webhook_secret) || System.get_env("GITHUB_WEBHOOK_SECRET")
    signature = WebhookRequest.header(request, "x-hub-signature-256")
    raw = raw_body(request)

    cond do
      secret in [nil, ""] -> {:error, :missing_webhook_secret}
      signature in [nil, ""] -> {:error, :missing_signature}
      secure_compare(signature, "sha256=" <> hmac(secret, raw)) -> :ok
      true -> {:error, :invalid_signature}
    end
  end

  def verify_webhook(request, opts) when is_map(request) do
    request
    |> WebhookRequest.new()
    |> verify_webhook(opts)
  end

  @impl true
  def parse_event(request, opts \\ [])

  def parse_event(%WebhookRequest{} = request, _opts) do
    event = WebhookRequest.header(request, "x-github-event")
    action = request.payload["action"]

    case {event, action} do
      {"ping", _} ->
        {:ok, :noop}

      {"issue_comment", action} when action in ["created", "edited"] ->
        with {:ok, incoming} <- transform_incoming(request.payload) do
          {:ok, envelope(incoming, request)}
        end

      {"issues", action} when action in ["opened", "edited", "reopened"] ->
        with {:ok, incoming} <- transform_incoming(request.payload) do
          {:ok, envelope(incoming, request)}
        end

      {"reaction", action} when action in ["created", "deleted"] ->
        with {:ok, reaction} <- reaction_from_payload(request.payload) do
          {:ok, reaction_envelope(reaction, request)}
        end

      _ ->
        {:ok, :noop}
    end
  end

  def parse_event(request, opts) when is_map(request) do
    request
    |> WebhookRequest.new()
    |> parse_event(opts)
  end

  @impl true
  def format_webhook_response({:ok, _chat, _incoming}, _opts), do: WebhookResponse.accepted()
  def format_webhook_response({:ok, :noop}, _opts), do: WebhookResponse.accepted()

  def format_webhook_response({:error, reason}, _opts),
    do: WebhookResponse.error(400, inspect(reason))

  def format_webhook_response(_, _opts), do: WebhookResponse.accepted()

  defp incoming_from_comment(comment, issue, repo, payload) do
    room_id = room_id(repo, issue)
    user = comment["user"] || %{}

    Incoming.new(%{
      external_room_id: room_id,
      external_thread_id: to_string(issue["id"] || issue["node_id"] || issue["number"]),
      external_message_id: to_string(comment["id"]),
      external_user_id: user["id"] || user["login"],
      text: comment["body"] || "",
      timestamp: comment["created_at"],
      author: author(user),
      chat_type: :github_issue,
      chat_title: issue["title"],
      raw: payload,
      media: media_from_markdown(comment["body"]),
      metadata: %{"html_url" => comment["html_url"], "issue_number" => issue["number"]}
    })
  end

  defp incoming_from_issue(issue, repo, payload) do
    room_id = room_id(repo, issue)
    user = issue["user"] || %{}

    Incoming.new(%{
      external_room_id: room_id,
      external_thread_id: to_string(issue["id"] || issue["node_id"] || issue["number"]),
      external_message_id: to_string(issue["number"] || issue["id"]),
      external_user_id: user["id"] || user["login"],
      text: issue["body"] || issue["title"] || "",
      timestamp: issue["created_at"],
      author: author(user),
      chat_type: :github_issue,
      chat_title: issue["title"],
      raw: payload,
      media: media_from_markdown(issue["body"]),
      metadata: %{
        "html_url" => issue["html_url"],
        "issue_number" => issue["number"],
        "issue_event" => true
      }
    })
  end

  defp post_thread_payload(room_id, target, %PostPayload{} = payload, opts) do
    with {:ok, body} <- render_post_body(payload, opts),
         {:ok, raw} <-
           transport(opts).create_issue_comment(
             target.owner,
             target.repo,
             target.issue_number,
             body,
             opts
           ) do
      {:ok,
       raw
       |> response_from_comment(room_id)
       |> put_response_metadata(:attachments, PostPayload.outbound_attachments(payload))}
    end
  end

  defp post_repo_payload(repo_id, %PostPayload{} = payload, opts) do
    with {:ok, target} <- parse_repo_id(repo_id),
         {:ok, title} <- issue_title(payload, opts),
         {:ok, body} <- render_post_body(payload, opts),
         {:ok, raw} <- transport(opts).create_issue(target.owner, target.repo, title, body, opts) do
      {:ok,
       raw
       |> response_from_issue(target)
       |> put_response_metadata(:attachments, PostPayload.outbound_attachments(payload))}
    end
  end

  defp message_from_comment(comment, room_id) do
    Message.new(%{
      id: to_string(comment["id"]),
      thread_id: thread_id(room_id),
      channel_id: room_id,
      text: comment["body"],
      raw: comment,
      author: author(comment["user"] || %{}),
      attachments: media_from_markdown(comment["body"]),
      created_at: comment["created_at"],
      updated_at: comment["updated_at"],
      external_message_id: to_string(comment["id"]),
      external_room_id: room_id,
      metadata: %{"html_url" => comment["html_url"]}
    })
  end

  defp issue_message_from_issue(issue, target) do
    repo = %{"name" => target.repo, "owner" => %{"login" => target.owner}}
    room_id = room_id(repo, issue)

    Message.new(%{
      id: to_string(issue["number"] || issue["id"]),
      thread_id: thread_id(room_id),
      channel_id: "github:#{target.owner}/#{target.repo}",
      text: issue["body"] || issue["title"],
      raw: issue,
      author: author(issue["user"] || %{}),
      attachments: media_from_markdown(issue["body"]),
      created_at: issue["created_at"],
      updated_at: issue["updated_at"],
      external_message_id: to_string(issue["number"] || issue["id"]),
      external_room_id: "#{target.owner}/#{target.repo}",
      metadata: %{
        "html_url" => issue["html_url"],
        "issue_number" => issue["number"],
        "thread_room_id" => room_id
      }
    })
  end

  defp response_from_comment(comment, room_id, status \\ :sent) do
    Response.new(%{
      external_message_id: to_string(comment["id"]),
      external_room_id: room_id,
      timestamp: comment["created_at"],
      channel_type: :github,
      status: status,
      raw: comment,
      metadata: %{"html_url" => comment["html_url"]}
    })
  end

  defp response_from_issue(issue, target, status \\ :sent) do
    room_id = "#{target.owner}/#{target.repo}##{issue["number"]}"

    Response.new(%{
      external_message_id: to_string(issue["number"] || issue["id"]),
      external_room_id: room_id,
      timestamp: issue["created_at"],
      channel_type: :github,
      status: status,
      raw: issue,
      metadata: %{
        "html_url" => issue["html_url"],
        "issue_number" => issue["number"],
        "repo" => "#{target.owner}/#{target.repo}"
      }
    })
  end

  defp put_response_metadata(%Response{} = response, key, value) do
    %{response | metadata: Map.put(response.metadata || %{}, key, value)}
  end

  defp route_parsed_event(chat, :noop, _opts, %WebhookRequest{} = request) do
    {:ok, chat, synthetic_incoming(request, :noop)}
  end

  defp route_parsed_event(chat, %EventEnvelope{} = envelope, opts, _request) do
    with {:ok, updated_chat, routed_envelope} <-
           Jido.Chat.process_event(chat, :github, envelope, opts),
         {:ok, incoming} <- incoming_from_event(routed_envelope) do
      {:ok, updated_chat, incoming}
    end
  end

  defp incoming_from_event(%EventEnvelope{event_type: :message, payload: %Incoming{} = incoming}),
    do: {:ok, incoming}

  defp incoming_from_event(%EventEnvelope{
         event_type: :reaction,
         payload: %ReactionEvent{} = reaction
       }) do
    {:ok,
     Incoming.new(%{
       external_room_id: reaction.channel_id,
       external_user_id: reaction.user && reaction.user.user_id,
       external_message_id: reaction.message_id,
       text: nil,
       raw: reaction.raw,
       metadata:
         Map.merge(reaction.metadata || %{}, %{
           "event_type" => :reaction,
           "emoji" => reaction.emoji,
           "added" => reaction.added
         })
     })}
  end

  defp incoming_from_event(_), do: {:error, :unsupported_event_type}

  defp synthetic_incoming(%WebhookRequest{} = request, event_type) do
    Incoming.new(%{
      external_room_id: "github",
      external_user_id: nil,
      external_message_id: WebhookRequest.header(request, "x-github-delivery"),
      text: nil,
      raw: request.payload,
      metadata: %{event_type: event_type}
    })
  end

  defp author(user) do
    Author.new(%{
      user_id: to_string(user["id"] || user["login"] || "unknown"),
      user_name: user["login"] || "unknown",
      full_name: user["login"],
      is_bot: user["type"] == "Bot",
      metadata: user
    })
  end

  defp reaction_from_payload(%{"reaction" => reaction, "issue" => issue} = payload) do
    repo = payload["repository"] || %{}
    comment = payload["comment"]
    room_id = room_id(repo, issue)
    user = reaction["user"] || payload["sender"] || %{}
    message_id = if is_map(comment), do: comment["id"], else: issue["number"] || issue["id"]

    {:ok,
     ReactionEvent.new(%{
       adapter_name: :github,
       thread_id: thread_id(room_id),
       channel_id: room_id,
       message_id: to_string(message_id),
       emoji: reaction["content"],
       added: payload["action"] != "deleted",
       user: author(user),
       raw: payload,
       metadata: %{
         "reaction_id" => reaction["id"],
         "issue_number" => issue["number"],
         "html_url" => if(is_map(comment), do: comment["html_url"], else: issue["html_url"])
       }
     })}
  end

  defp reaction_from_payload(_payload), do: {:error, :unsupported_payload}

  defp envelope(incoming, request) do
    EventEnvelope.new(%{
      adapter_name: :github,
      event_type: :message,
      thread_id: thread_id(incoming.external_room_id),
      channel_id: incoming.external_room_id,
      message_id: to_string(incoming.external_message_id),
      payload: incoming,
      raw: request.payload,
      metadata: %{"delivery" => WebhookRequest.header(request, "x-github-delivery")}
    })
  end

  defp reaction_envelope(%ReactionEvent{} = reaction, request) do
    EventEnvelope.new(%{
      adapter_name: :github,
      event_type: :reaction,
      thread_id: reaction.thread_id,
      channel_id: reaction.channel_id,
      message_id: reaction.message_id,
      payload: reaction,
      raw: request.payload,
      metadata: %{"delivery" => WebhookRequest.header(request, "x-github-delivery")}
    })
  end

  defp thread_from_issue(issue, target) do
    room_id = "#{target.owner}/#{target.repo}##{issue["number"] || target.issue_number}"

    Thread.new(%{
      id: thread_id(room_id),
      adapter_name: :github,
      adapter: __MODULE__,
      external_room_id: room_id,
      external_thread_id: to_string(issue["id"] || issue["node_id"] || target.issue_number),
      channel_id: "github:#{target.owner}/#{target.repo}",
      metadata: %{"issue" => issue}
    })
  end

  defp thread_summary_from_issue(issue, target) do
    repo = %{"name" => target.repo, "owner" => %{"login" => target.owner}}
    room_id = room_id(repo, issue)
    incoming = incoming_from_issue(issue, repo, %{"issue" => issue, "repository" => repo})

    ThreadSummary.new(%{
      id: thread_id(room_id),
      last_reply_at: issue["updated_at"],
      reply_count: issue["comments"] || 0,
      root_message:
        Message.from_incoming(incoming, adapter_name: :github, thread_id: thread_id(room_id)),
      metadata: %{"issue" => issue}
    })
  end

  defp render_text_body(text, opts) do
    text
    |> to_string()
    |> sections_with_reply(opts, [])
    |> render_sections()
  end

  defp render_post_body(%PostPayload{} = payload, opts) do
    base =
      payload.markdown || payload.formatted || PostPayload.display_text(payload) ||
        payload.fallback_text

    with {:ok, attachment_sections} <-
           attachment_sections(PostPayload.outbound_attachments(payload)) do
      base
      |> blank_to_nil()
      |> sections_with_reply(opts, attachment_sections)
      |> render_sections()
    end
  end

  defp issue_title(%PostPayload{} = payload, opts) do
    [
      Keyword.get(opts, :title),
      Keyword.get(opts, :issue_title),
      metadata_value(payload.metadata, :title),
      PostPayload.display_text(payload),
      payload.markdown,
      payload.formatted,
      payload.fallback_text
    ]
    |> Enum.find_value(&first_title_line/1)
    |> case do
      nil -> {:error, :missing_issue_title}
      title -> {:ok, String.slice(title, 0, 256)}
    end
  end

  defp first_title_line(nil), do: nil

  defp first_title_line(value) do
    value
    |> to_string()
    |> String.split("\n")
    |> Enum.find_value(fn line ->
      case String.trim(line) do
        "" -> nil
        title -> title
      end
    end)
  end

  defp metadata_value(metadata, key) when is_map(metadata),
    do: Map.get(metadata, key) || Map.get(metadata, to_string(key))

  defp metadata_value(_metadata, _key), do: nil

  defp sections_with_reply(base, opts, extra_sections) do
    [reply_section(opts), blank_to_nil(base) | extra_sections]
  end

  defp render_sections(sections) do
    body =
      sections
      |> Enum.reject(&is_nil/1)
      |> Enum.join("\n\n")
      |> String.trim()

    if body == "", do: {:error, :empty_message}, else: {:ok, body}
  end

  defp reply_section(opts) do
    %{
      text: Keyword.get(opts, :reply_to_text) || Keyword.get(opts, :quote_text),
      message: Keyword.get(opts, :reply_to_id) || Keyword.get(opts, :quote_id),
      author: Keyword.get(opts, :reply_author) || Keyword.get(opts, :quote_author)
    }
    |> render_reply_section()
  end

  defp render_reply_section(%{text: text, message: message, author: author}) do
    text = blank_to_nil(text)
    message = blank_to_nil(message)
    author = blank_to_nil(author)

    case {text, message} do
      {nil, nil} -> nil
      {nil, message} -> "Replying to #{message}."
      {text, message} -> "#{reply_heading(author, message)}\n#{quote_text(text)}"
    end
  end

  defp reply_heading(nil, nil), do: "Replying to:"
  defp reply_heading(nil, message), do: "Replying to #{message}:"
  defp reply_heading(author, nil), do: "Replying to #{author}:"
  defp reply_heading(author, message), do: "Replying to #{author} in #{message}:"

  defp quote_text(text) do
    text
    |> String.split("\n")
    |> Enum.map_join("\n", &("> " <> &1))
  end

  defp attachment_sections(attachments) when is_list(attachments) do
    Enum.reduce_while(attachments, {:ok, []}, fn attachment, {:ok, acc} ->
      case attachment_section(Attachment.normalize(attachment)) do
        {:ok, section} -> {:cont, {:ok, [section | acc]}}
        {:error, _reason} = error -> {:halt, error}
      end
    end)
    |> case do
      {:ok, sections} -> {:ok, Enum.reverse(sections)}
      {:error, _reason} = error -> error
    end
  end

  defp attachment_section(%Attachment{url: url} = attachment) when is_binary(url) and url != "" do
    label = attachment_label(attachment, url)

    section =
      case attachment.kind do
        :image -> "![#{escape_markdown_label(label)}](#{url})"
        _ -> "[#{escape_markdown_label(label)}](#{url})"
      end

    {:ok, section}
  end

  defp attachment_section(%Attachment{}),
    do: {:error, {:unsupported_file_upload, :github_requires_public_url}}

  defp attachment_label(%Attachment{} = attachment, url) do
    attachment.metadata[:alt_text] ||
      attachment.metadata["alt_text"] ||
      attachment.filename ||
      filename_from_url(url) ||
      "attachment"
  end

  defp media_from_markdown(nil), do: []
  defp media_from_markdown(""), do: []

  defp media_from_markdown(markdown) when is_binary(markdown) do
    images =
      ~r/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)/
      |> Regex.scan(markdown)
      |> Enum.map(fn [_match, alt, url] ->
        Media.new(%{
          kind: :image,
          url: url,
          filename: filename_from_url(url),
          metadata: %{"alt_text" => alt}
        })
      end)

    linked_files =
      ~r/(?<!!)\[([^\]]+)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)/
      |> Regex.scan(markdown)
      |> Enum.map(fn [_match, label, url] ->
        Media.new(%{url: url, filename: label || filename_from_url(url)})
      end)

    images ++ linked_files
  end

  defp github_reaction(emoji) do
    normalized =
      emoji
      |> to_string()
      |> String.trim()
      |> String.trim(":")
      |> String.downcase()

    case Map.fetch(@github_reactions, normalized) do
      {:ok, content} -> {:ok, content}
      :error -> {:error, {:unsupported_reaction, normalized}}
    end
  end

  defp reaction_id(target, message_id, content, opts) do
    case Keyword.get(opts, :reaction_id) do
      reaction_id when reaction_id not in [nil, ""] ->
        {:ok, reaction_id}

      _ ->
        find_reaction_id(target, message_id, content, opts)
    end
  end

  defp find_reaction_id(target, message_id, content, opts) do
    with {:ok, reactions} <- list_reactions(target, message_id, opts) do
      reactions
      |> Enum.find(&(Map.get(&1, "content") == content and reaction_user_matches?(&1, opts)))
      |> reaction_id_from_match(content)
    end
  end

  defp reaction_id_from_match(%{"id" => id}, _content), do: {:ok, id}
  defp reaction_id_from_match(_reaction, content), do: {:error, {:reaction_not_found, content}}

  defp list_reactions(target, message_id, opts) do
    case reaction_target(opts) do
      :issue ->
        transport(opts).list_issue_reactions(
          target.owner,
          target.repo,
          target.issue_number,
          opts
        )

      :comment ->
        transport(opts).list_issue_comment_reactions(target.owner, target.repo, message_id, opts)
    end
  end

  defp reaction_user_matches?(reaction, opts) do
    case Keyword.get(opts, :user_login) do
      user_login when user_login in [nil, ""] -> true
      user_login -> get_in(reaction, ["user", "login"]) == user_login
    end
  end

  defp reaction_target(opts) do
    case Keyword.get(opts, :target, Keyword.get(opts, :target_type, :comment)) do
      value when value in [:issue, "issue"] -> :issue
      _ -> :comment
    end
  end

  defp open_thread_room_id(room_or_repo_id, issue_number) do
    case parse_room_id(room_or_repo_id) do
      {:ok, _target} ->
        {:ok, room_or_repo_id}

      {:error, _reason} ->
        with {:ok, repo} <- parse_repo_id(room_or_repo_id),
             {:ok, issue_number} <- parse_issue_number(issue_number) do
          {:ok, "#{repo.owner}/#{repo.repo}##{issue_number}"}
        end
    end
  end

  defp room_id(repo, issue), do: "#{repo["owner"]["login"]}/#{repo["name"]}##{issue["number"]}"
  defp thread_id(room_id), do: "github:#{room_id}"

  defp parse_room_id(room_id) when is_binary(room_id) do
    case Regex.run(~r{\A([^/\s]+)/([^#\s]+)#(\d+)\z}, room_id) do
      [_, owner, repo, issue_number] ->
        {:ok, %{owner: owner, repo: repo, issue_number: String.to_integer(issue_number)}}

      _ ->
        {:error, :invalid_room_id}
    end
  end

  defp parse_room_id(_), do: {:error, :invalid_room_id}

  defp parse_repo_id(repo_id) when is_binary(repo_id) do
    case Regex.run(~r{\A([^/\s]+)/([^#\s]+)(?:#\d+)?\z}, repo_id) do
      [_, owner, repo] -> {:ok, %{owner: owner, repo: repo}}
      _ -> {:error, :invalid_repo_id}
    end
  end

  defp parse_repo_id(_), do: {:error, :invalid_repo_id}

  defp parse_issue_number(value) when is_integer(value), do: {:ok, value}

  defp parse_issue_number(value) when is_binary(value) do
    case Integer.parse(value) do
      {number, ""} -> {:ok, number}
      _ -> {:error, :invalid_issue_number}
    end
  end

  defp parse_issue_number(_value), do: {:error, :invalid_issue_number}

  defp pull_request_issue?(%{"pull_request" => pull_request}) when is_map(pull_request),
    do: true

  defp pull_request_issue?(_issue), do: false

  defp filename_from_url(url) when is_binary(url) do
    url
    |> URI.parse()
    |> Map.get(:path)
    |> case do
      nil -> nil
      "" -> nil
      path -> path |> Path.basename() |> URI.decode()
    end
  rescue
    _ -> nil
  end

  defp filename_from_url(_url), do: nil

  defp escape_markdown_label(label) do
    label
    |> to_string()
    |> String.replace("[", "\\[")
    |> String.replace("]", "\\]")
  end

  defp blank_to_nil(nil), do: nil
  defp blank_to_nil(""), do: nil
  defp blank_to_nil(value) when is_binary(value), do: value
  defp blank_to_nil(value), do: to_string(value)

  defp transport(opts), do: Keyword.get(opts, :transport, ReqClient)
  defp raw_body(%WebhookRequest{raw: raw}) when is_binary(raw), do: raw
  defp raw_body(%WebhookRequest{metadata: %{"raw_body" => raw}}) when is_binary(raw), do: raw
  defp raw_body(%WebhookRequest{metadata: %{raw_body: raw}}) when is_binary(raw), do: raw
  defp raw_body(%WebhookRequest{payload: payload}), do: Jason.encode!(payload)

  defp hmac(secret, data),
    do: :crypto.mac(:hmac, :sha256, secret, data) |> Base.encode16(case: :lower)

  defp secure_compare(a, b) when byte_size(a) == byte_size(b) do
    :crypto.hash_equals(a, b)
  end

  defp secure_compare(_, _), do: false
end