defmodule JSON.LD.DocumentLoader.RemoteDocument do
  @moduledoc """
  Implementation of the JSON-LD 1.1 Remote Document and Context Retrieval specification.

  This module provides both:

  1. A struct representing remote documents as specified in https://www.w3.org/TR/json-ld11-api/#remotedocument
  2. The core implementation of remote document loading according to
     <https://www.w3.org/TR/json-ld11-api/#remote-document-and-context-retrieval> so that
     custom `JSON.LD.DocumentLoader` implementations can reuse this by calling `load/3` or
     implementing their own loading logic.

  ## Custom HTTP clients

  The default Tesla-based HTTP client is `JSON.LD.DocumentLoader.DefaultClient`.

  If you need a custom HTTP client with custom middleware, you can create your own module
  that implements a `client/3` function:

      defmodule MyCustomClient do
        use Tesla

        def client(headers, url, options) do
          [
            {Tesla.Middleware.Headers, headers},
            # your custom middleware
          ]
          |> Tesla.client()
        end
      end

  and configure it as:

      config :json_ld, :http_client, MyCustomClient

  """

  defstruct [:context_url, :document_url, :document, :content_type, :profile]

  @type t :: %__MODULE__{
          context_url: String.t() | nil,
          document_url: String.t(),
          document: any,
          content_type: String.t(),
          profile: String.t() | nil
        }

  alias JSON.LD.Options
  alias JSON.LD.DocumentLoader.DefaultClient
  alias RDF.IRI

  def default_http_client do
    Application.get_env(:json_ld, :http_client, DefaultClient)
  end

  @doc """
  Loads a remote document from the given URL.

  According to <https://www.w3.org/TR/json-ld11-api/#remote-document-and-context-retrieval>
  """
  @spec load(String.t(), Options.convertible(), module) :: {:ok, t()} | {:error, any}
  def load(url, options \\ [], http_client \\ default_http_client()) do
    do_load(url, http_client, Options.new(options))
  end

  defp do_load(url, http_client, options, visited_urls \\ []) do
    if url in visited_urls do
      {:error,
       JSON.LD.Error.loading_document_failed("Circular reference detected in document loading")}
    else
      case http_get(http_client, url, options) do
        {:ok, %Tesla.Env{status: status} = response} when status in 200..299 ->
          # 3)
          document_url = response.url
          content_type = get_content_type(response.headers)
          profile = get_profile_from_content_type(response.headers)

          cond do
            # The HTTP Link Header is ignored for documents served as application/ld+json ...
            content_type == "application/ld+json" ->
              with {:ok, document} <- parse_json(response.body) do
                {:ok,
                 %__MODULE__{
                   document: document,
                   document_url: document_url,
                   content_type: content_type,
                   context_url: nil,
                   profile: profile
                 }}
              end

            # 5)
            content_type &&
                (String.starts_with?(content_type, "application/json") ||
                   String.contains?(content_type, "+json")) ->
              with {:ok, document} <- parse_json(response.body) do
                case find_context_links(response.headers) do
                  {:ok, nil} ->
                    {:ok,
                     %__MODULE__{
                       document: document,
                       document_url: document_url,
                       content_type: content_type,
                       context_url: nil,
                       profile: profile
                     }}

                  {:ok, context_url} ->
                    {:ok,
                     %__MODULE__{
                       document: document,
                       document_url: document_url,
                       content_type: content_type,
                       context_url: document_url |> IRI.merge(context_url) |> to_string(),
                       profile: profile
                     }}

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

            # 4)
            true ->
              if alternate_url = find_alternate_link(response.headers) do
                document_url
                |> IRI.merge(alternate_url)
                |> to_string()
                |> do_load(http_client, options, [url | visited_urls])
              else
                # 6)
                {:error,
                 JSON.LD.Error.loading_document_failed(
                   "Retrieved resource's Content-Type is not JSON-compatible: #{content_type}"
                 )}
              end
          end

        {:ok, %{status: status}} ->
          {:error,
           JSON.LD.Error.loading_document_failed("HTTP request failed with status #{status}")}

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

  def load!(url, options \\ [], http_client \\ default_http_client()) do
    case load(url, options, http_client) do
      {:ok, remote_document} -> remote_document
      {:error, error} -> raise error
    end
  end

  # 2)
  def http_get(http_client, url, options) do
    options.request_profile
    |> build_headers()
    |> http_client.client(url, options)
    |> Tesla.get(url)
  rescue
    e -> {:error, JSON.LD.Error.loading_document_failed("HTTP request failed: #{inspect(e)}")}
  end

  defp build_headers(request_profile) do
    [
      {"accept",
       if request_profile do
         "application/ld+json;profile=\"#{request_profile |> List.wrap() |> Enum.join(" ")}\", application/json"
       else
         "application/ld+json, application/json"
       end}
    ]
  end

  defp get_content_type(headers) do
    case Enum.find(headers, fn {name, _} -> String.downcase(name) == "content-type" end) do
      {_, content_type} ->
        [base_type | _] = String.split(content_type, ";", parts: 2)
        String.trim(base_type)

      _ ->
        nil
    end
  end

  defp get_profile_from_content_type(headers) do
    case Enum.find(headers, fn {name, _} -> String.downcase(name) == "content-type" end) do
      {_, content_type} ->
        case Regex.run(~r/profile="?([^;"]+)"?/, content_type) do
          [_, profile] -> profile
          _ -> nil
        end

      _ ->
        nil
    end
  end

  defp find_context_links(headers) do
    headers
    |> Enum.filter(fn {name, value} ->
      String.downcase(name) == "link" &&
        String.contains?(String.downcase(value), "http://www.w3.org/ns/json-ld#context")
    end)
    |> Enum.flat_map(fn {_, value} ->
      value
      |> parse_link_headers()
      |> Enum.filter(fn {_, props} -> props["rel"] == "http://www.w3.org/ns/json-ld#context" end)
    end)
    |> case do
      [] -> {:ok, nil}
      [{url, _}] -> {:ok, url}
      _ -> {:error, JSON.LD.Error.multiple_context_link_headers()}
    end
  end

  defp find_alternate_link(headers) do
    headers
    |> Stream.filter(fn {name, _} -> String.downcase(name) == "link" end)
    |> Stream.flat_map(fn {_, value} ->
      value
      |> parse_link_headers()
      |> Stream.filter(fn {_, props} ->
        props["rel"] == "alternate" && props["type"] == "application/ld+json"
      end)
    end)
    |> Enum.find(fn {url, _} -> url end)
    |> case do
      nil -> nil
      {url, _} -> url
    end
  end

  defp parse_link_headers(value) do
    value
    |> String.split(",")
    |> Enum.map(&String.trim/1)
    |> Enum.map(&parse_link/1)
    |> Enum.reject(&is_nil/1)
  end

  defp parse_link(link_str) do
    with [url_part | param_parts] <- String.split(link_str, ";"),
         [_, url] <- Regex.run(~r/\A\s*<([^>]+)>\s*\Z/, url_part) do
      props =
        param_parts
        |> Enum.map(&String.trim/1)
        |> Enum.map(fn param ->
          case Regex.run(~r/\A([^=]+)=(?:"([^"]+)"|([^"]\S*))\Z/, param) do
            [_, key, value] -> {String.downcase(key), value}
            _ -> nil
          end
        end)
        |> Enum.reject(&is_nil/1)
        |> Map.new()

      {url, props}
    else
      _ -> nil
    end
  end

  defp parse_json(document) do
    case Jason.decode(document) do
      {:ok, _} = ok ->
        ok

      {:error, %Jason.DecodeError{} = error} ->
        {:error,
         JSON.LD.Error.loading_document_failed("JSON parsing failed: #{Exception.message(error)}")}
    end
  end
end
