lib/edgar.ex

defmodule EDGAR do
  use Application

  @edgar_archives_url "https://www.sec.gov/Archives/edgar"
  @edgar_data_url "https://data.sec.gov"
  @edgar_files_url "https://www.sec.gov/files"

  @type success_type(inner) :: {:ok, inner}
  @type error_type :: {:error, String.t()}

  def start(_type, _args) do
    children = [
      {SimpleRateLimiter, interval: 1_000, max: 10}
    ]

    opts = [strategy: :one_for_one, name: EDGAR.Supervisor]
    Supervisor.start_link(children, opts)
  end

  @doc """
  Fetches a list of company tickers
  """
  @spec company_tickers :: success_type(list()) | error_type()
  def company_tickers() do
    case get("#{@edgar_files_url}/company_tickers.json") do
      {:ok, result} ->
        {:ok, Map.values(result)}

      error ->
        error
    end
  end

  @doc """
  Fetches a list of company tickers with exchange
  """
  @spec company_tickers_with_exchange :: success_type(list()) | error_type()
  def company_tickers_with_exchange do
    case get("#{@edgar_files_url}/company_tickers_exchange.json") do
      {:ok, %{"data" => data, "fields" => fields}} ->
        result = for row <- data, into: [], do: Enum.zip(fields, row) |> Enum.into(%{})
        {:ok, result}

      error ->
        error
    end
  end

  @doc """
  Fetches a list of mutual fund tickers
  """
  @spec mutual_fund_tickers :: success_type(list()) | error_type()
  def mutual_fund_tickers do
    case get("#{@edgar_files_url}/company_tickers_mf.json") do
      {:ok, %{"data" => data, "fields" => fields}} ->
        result = for row <- data, into: [], do: Enum.zip(fields, row) |> Enum.into(%{})
        {:ok, result}

      error ->
        error
    end
  end

  @doc """
  Fetches a CIK for a given company ticker

  ## Required

  * `ticker` - The ticker of the company
  """
  @spec company_cik(ticker :: String.t()) :: success_type(String.t()) | error_type()
  def company_cik(ticker) do
    upcase_ticker = String.upcase(ticker)

    case company_tickers() do
      {:ok, tickers} ->
        ticker_data = Enum.find(tickers, fn t -> t["ticker"] == upcase_ticker end)

        case ticker_data do
          nil ->
            {:error, "ticker not found"}

          _ ->
            {:ok, ticker_data["cik_str"]}
        end

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

  @doc """
  Fetches a CIK for a given mutual fund ticker

  ## Required

  * `ticker` - The ticker of the mutual fund
  """
  @spec mutual_fund_cik(ticker :: String.t()) :: success_type(String.t()) | error_type()
  def mutual_fund_cik(ticker) do
    upcase_ticker = String.upcase(ticker)

    case mutual_fund_tickers() do
      {:ok, tickers} ->
        ticker_data = Enum.find(tickers, fn t -> t["symbol"] == upcase_ticker end)

        case ticker_data do
          nil ->
            {:error, "ticker not found"}

          _ ->
            {:ok, ticker_data["cik"]}
        end

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

  @doc """
  Fetches the entity directory

  ## Required

  * `cik` - The CIK of the entity
  """
  @spec entity_directory(cik :: String.t()) :: success_type(map()) | error_type()
  def entity_directory(cik) do
    padded_cik = String.pad_leading(cik, 10, "0")

    case get("#{@edgar_archives_url}/data/#{padded_cik}/index.json") do
      {:ok, resp} ->
        Jason.decode(resp)

      error ->
        error
    end
  end

  @doc """
  Fetches the filing directory

  ## Required

  * `cik` - The CIK of the entity
  * `accession_number` - The accession number of the filing
  """
  @spec filing_directory(cik :: String.t(), accession_number :: String.t()) ::
          success_type(map()) | error_type()
  def filing_directory(cik, accession_number) do
    formatted_acc_no = String.replace(accession_number, "-", "")

    case get("#{@edgar_archives_url}/data/#{cik}/#{formatted_acc_no}/index.json") do
      {:ok, resp} ->
        Jason.decode(resp)

      error ->
        error
    end
  end

  @doc """
  Fetches the daily index

  ## Optional

  * `year` - The year of the daily index (must be 1994 or greater)
  * `quarter` - The quarter of the daily index
  """
  @spec daily_index(year :: nil | integer(), quarter :: nil | integer()) ::
          success_type(map()) | error_type()
  def daily_index(year \\ nil, quarter \\ nil) do
    case {year, quarter} do
      {nil, nil} ->
        get("#{@edgar_archives_url}/daily-index/index.json")

      {year, _} when year < 1994 ->
        {:error, "year must be 1994 or greater"}

      {year, nil} ->
        get("#{@edgar_archives_url}/daily-index/#{Integer.to_string(year)}/index.json")

      {_, quarter} when quarter < 1 or quarter > 4 ->
        {:error, "quarter must be between 1 and 4"}

      {year, quarter} ->
        year_str = Integer.to_string(year)
        quarter_str = Integer.to_string(quarter)
        get("#{@edgar_archives_url}/daily-index/#{year_str}/QTR#{quarter_str}/index.json")
    end
  end

  @doc """
  Fetches the full index

  ## Optional

  * `year` - The year of the full index (must be 1994 or greater)
  * `quarter` - The quarter of the full index
  """
  @spec full_index(year :: nil | integer(), quarter :: nil | integer()) ::
          success_type(map()) | error_type()
  def full_index(year \\ nil, quarter \\ nil) do
    case {year, quarter} do
      {nil, nil} ->
        get("#{@edgar_archives_url}/full-index/index.json")

      {year, _} when year < 1994 ->
        {:error, "year must be 1994 or greater"}

      {year, nil} ->
        get("#{@edgar_archives_url}/full-index/#{Integer.to_string(year)}/index.json")

      {_, quarter} when quarter < 1 or quarter > 4 ->
        {:error, "quarter must be between 1 and 4"}

      {year, quarter} ->
        year_str = Integer.to_string(year)
        quarter_str = Integer.to_string(quarter)
        get("#{@edgar_archives_url}/full-index/#{year_str}/QTR#{quarter_str}/index.json")
    end
  end

  @doc """
  Parses a company index file from url

  ## Required

  * `url` - The url to the company index file to parse
  """
  @spec company_index_from_url(url :: String.t()) :: success_type(list(map())) | error_type()
  def company_index_from_url(url) do
    with {:ok, file} <- get(url), do: company_index_from_string(file)
  end

  @doc """
  Parses a company index file from file

  ## Required

  * `file_path` - The path to the company index file to parse
  """
  @spec company_index_from_file(file_path :: String.t()) ::
          success_type(list(map())) | error_type()
  def company_index_from_file(file_path) do
    with {:ok, file_content} <- File.read(file_path), do: company_index_from_string(file_content)
  end

  @doc """
  Parses a company index file from string

  ## Required

  * `file_content` - The content of the company index file to parse
  """
  @spec company_index_from_string(file_content :: String.t()) ::
          success_type(list(map())) | error_type()
  def company_index_from_string(file_content) do
    file_content
    |> String.split("\n")
    |> Enum.drop_while(&(!String.match?(&1, ~r/^-+$/)))
    |> Enum.drop(1)
    |> Stream.map(fn line ->
      case Regex.scan(~r/(.{60})(.{10})(.{12})(.{14})(.+)/, String.trim(line)) do
        [match] ->
          [_, company_name, form_type, cik, date_filed, file_name] =
            Enum.map(match, &String.trim/1)

          %{
            "company_name" => company_name,
            "form_type" => form_type,
            "cik" => cik,
            "date_filed" => date_filed,
            "file_name" => file_name
          }

        _ ->
          nil
      end
    end)
    |> Enum.reject(&is_nil/1)
    |> (&{:ok, &1}).()
  end

  @doc """
  Parses a crawler index file from url

  ## Required

  * `url` - The url to the crawler index file to parse
  """
  @spec crawler_index_from_url(url :: String.t()) :: success_type(list(map())) | error_type()
  def crawler_index_from_url(url) do
    with {:ok, file} <- get(url), do: crawler_index_from_string(file)
  end

  @doc """
  Parses a crawler index file from file

  ## Required

  * `file_path` - The path to the crawler index file to parse
  """
  @spec crawler_index_from_file(file_path :: String.t()) ::
          success_type(list(map())) | error_type()
  def crawler_index_from_file(file_path) do
    with {:ok, file_content} <- File.read(file_path), do: crawler_index_from_string(file_content)
  end

  @doc """
  Parses a crawler index file from a string

  ## Required

  * `file_content` - The content of the crawler index file to parse
  """
  def crawler_index_from_string(file_content) do
    file_content
    |> String.split("\n")
    |> Enum.drop_while(&(!String.match?(&1, ~r/^-+$/)))
    |> Enum.drop(1)
    |> Stream.map(fn line ->
      case Regex.scan(~r/(.{60})(.{10})(.{12})(.{12})(.+)/, String.trim(line)) do
        [match] ->
          [_, company_name, form_type, cik, date_filed, url] =
            Enum.map(match, &String.trim/1)

          %{
            "company_name" => company_name,
            "form_type" => form_type,
            "cik" => cik,
            "date_filed" => date_filed,
            "url" => url
          }

        _ ->
          nil
      end
    end)
    |> Enum.reject(&is_nil/1)
    |> (&{:ok, &1}).()
  end

  @doc """
  Parses a form index file from url

  ## Required

  * `url` - The url to the form index file to parse
  """
  @spec form_index_from_url(url :: String.t()) :: success_type(list(map())) | error_type()
  def form_index_from_url(url) do
    with {:ok, file} <- get(url), do: form_index_from_string(file)
  end

  @doc """
  Parses a form index file from file

  ## Required

  * `file_path` - The path to the form index file to parse
  """
  @spec form_index_from_file(file_path :: String.t()) ::
          success_type(list(map())) | error_type()
  def form_index_from_file(file_path) do
    with {:ok, file_content} <- File.read(file_path), do: form_index_from_string(file_content)
  end

  @doc """
  Parses a form index file from a string

  ## Required

  * `file_content` - The content of the form index file to parse
  """
  def form_index_from_string(file_content) do
    file_content
    |> String.split("\n")
    |> Enum.drop_while(&(!String.match?(&1, ~r/^-+$/)))
    |> Enum.drop(1)
    |> Stream.map(fn line ->
      case Regex.scan(~r/(.{10})(.{60})(.{12})(.{12})(.+)/, String.trim(line)) do
        [match] ->
          [_, form_type, company_name, cik, date_filed, file_name] =
            Enum.map(match, &String.trim/1)

          %{
            "form_type" => form_type,
            "company_name" => company_name,
            "cik" => cik,
            "date_filed" => date_filed,
            "file_name" => file_name
          }

        _ ->
          nil
      end
    end)
    |> Enum.reject(&is_nil/1)
    |> (&{:ok, &1}).()
  end

  @doc """
  Parses a xbrl index file from url

  ## Required

  * `url` - The url to the xbrl file to parse
  """
  @spec xbrl_index_from_url(url :: String.t()) :: success_type(list(map())) | error_type()
  def xbrl_index_from_url(url) do
    with {:ok, file} <- get(url), do: xbrl_index_from_string(file)
  end

  @doc """
  Parses a xbrl index file from file

  ## Required

  * `file_path` - The path to the xbrl file to parse
  """
  @spec xbrl_index_from_file(file_path :: String.t()) ::
          success_type(list(map())) | error_type()
  def xbrl_index_from_file(file_path) do
    with {:ok, file_content} <- File.read(file_path), do: xbrl_index_from_string(file_content)
  end

  @doc """
  Parses a xbrl index file from a string

  ## Required

  * `file_content` - The content of the xbrl index file to parse
  """
  def xbrl_index_from_string(file_content) do
    file_content
    |> String.split("\n")
    |> Enum.drop_while(&(!String.match?(&1, ~r/^-+$/)))
    |> Enum.drop(1)
    |> Stream.map(fn line ->
      case Regex.scan(~r/^(.*?)\|(.*?)\|(.*?)\|(.*?)\|(.*?)$/, String.trim(line)) do
        [match] ->
          [_, cik, company_name, form_type, date_filed, file_name] =
            Enum.map(match, &String.trim/1)

          %{
            "form_type" => form_type,
            "company_name" => company_name,
            "cik" => cik,
            "date_filed" => date_filed,
            "file_name" => file_name
          }

        _ ->
          nil
      end
    end)
    |> Enum.reject(&is_nil/1)
    |> (&{:ok, &1}).()
  end

  @doc """
  Parses a master index file from url

  ## Required

  * `url` - The url to the master file to parse
  """
  @spec master_index_from_url(url :: String.t()) :: success_type(list(map())) | error_type()
  def master_index_from_url(url) do
    with {:ok, file} <- get(url), do: master_index_from_string(file)
  end

  @doc """
  Parses a master index file from file

  ## Required

  * `file_path` - The path to the master file to parse
  """
  @spec master_index_from_file(file_path :: String.t()) ::
          success_type(list(map())) | error_type()
  def master_index_from_file(file_path) do
    with {:ok, file_content} <- File.read(file_path), do: master_index_from_string(file_content)
  end

  @doc """
  Parses a master index file from a string

  ## Required

  * `file_content` - The content of the master index file to parse
  """
  def master_index_from_string(file_content) do
    file_content
    |> String.split("\n")
    |> Enum.drop_while(&(!String.match?(&1, ~r/^-+$/)))
    |> Enum.drop(1)
    |> Stream.map(fn line ->
      case Regex.scan(~r/^(.*?)\|(.*?)\|(.*?)\|(.*?)\|(.*?)$/, String.trim(line)) do
        [match] ->
          [_, cik, company_name, form_type, date_filed, file_name] =
            Enum.map(match, &String.trim/1)

          %{
            "form_type" => form_type,
            "company_name" => company_name,
            "cik" => cik,
            "date_filed" => date_filed,
            "file_name" => file_name
          }

        _ ->
          nil
      end
    end)
    |> Enum.reject(&is_nil/1)
    |> (&{:ok, &1}).()
  end

  @doc """
  Fetches submissions for a given CIK

  ## Required

  * `cik` - The CIK of the entity
  """
  @spec submissions(cik :: String.t()) :: success_type(map()) | error_type()
  def submissions(cik) do
    padded_cik = String.pad_leading(cik, 10, "0")

    "#{@edgar_data_url}/submissions/CIK#{padded_cik}.json"
    |> get()
  end

  @doc """
  Fetches company facts for a given CIK

  ## Required

  * `cik` - The CIK of the entity
  """
  @spec company_facts(cik :: String.t()) :: success_type(map()) | error_type()
  def company_facts(cik) do
    padded_cik = String.pad_leading(cik, 10, "0")

    "#{@edgar_data_url}/api/xbrl/companyfacts/CIK#{padded_cik}.json"
    |> get()
  end

  @doc """
  Fetches company concepts for a given CIK and concept (taxonomy, tag)

  ## Required

  * `cik` - The CIK of the entity
  * `taxonomy` - The taxonomy of the concept
  * `tag` - The tag of the concept
  """
  @spec company_concept(cik :: String.t(), taxonomy :: String.t(), tag :: String.t()) ::
          success_type(map()) | error_type()
  def company_concept(cik, taxonomy, tag) do
    padded_cik = String.pad_leading(cik, 10, "0")

    "#{@edgar_data_url}/api/xbrl/companyconcept/CIK#{padded_cik}/#{taxonomy}/#{tag}.json"
    |> get()
  end

  @doc """
  Fetches frames for a given taxonomy, concept, unit, and period

  ## Required

  * `taxonomy` - The taxonomy of the concept
  * `tag` - The tag of the concept
  * `unit` - The unit of the concept
  * `period` - The period of the concept
  """
  @spec frames(
          taxonomy :: String.t(),
          tag :: String.t(),
          unit :: String.t(),
          period :: String.t()
        ) ::
          success_type(map()) | error_type()
  def frames(taxonomy, tag, unit, period) do
    "#{@edgar_data_url}/api/xbrl/frames/#{taxonomy}/#{tag}/#{unit}/#{period}.json"
    |> get()
  end

  @doc """
  Fetches a list of filings from the submissions file

  ## Required

  * `cik` - The CIK of the entity

  ## Optional

  * `form_type` - The form type of the filing
  * `offset` - The offset of the filings
  * `limit` - The limit of the filings
  """
  @spec filings(cik :: String.t(), opt :: map()) :: success_type(list()) | error_type()
  def filings(cik, opts \\ %{}) do
    case submissions(cik) do
      {:ok, submissions} ->
        filings =
          submissions
          |> get_recent_filings()
          |> append_file_filings(submissions["filings"]["files"])
          |> form_type(opts[:form_type])
          |> offset(opts[:offset])
          |> limit(opts[:limit])

        {:ok, filings}

      error ->
        error
    end
  end

  defp get_recent_filings(submissions) do
    submissions["filings"]["recent"] |> format_filings()
  end

  defp append_file_filings(filings, files) do
    formatted_file_filings =
      Enum.flat_map(files, fn file ->
        {:ok, file_data} = get("#{@edgar_data_url}/submissions/#{file["name"]}")
        format_filings(file_data)
      end)

    filings ++ formatted_file_filings
  end

  defp form_type(filings, form_type) when is_nil(form_type), do: filings

  defp form_type(filings, form_type),
    do: Enum.filter(filings, fn filing -> filing["form"] == form_type end)

  defp offset(filings, offset) when is_nil(offset), do: filings
  defp offset(filings, offset), do: Enum.drop(filings, offset)

  defp limit(filings, limit) when is_nil(limit), do: filings
  defp limit(filings, limit), do: Enum.take(filings, limit)

  defp format_filings(filings) do
    field_names = [
      "acceptanceDateTime",
      "accessionNumber",
      "act",
      "fileNumber",
      "form",
      "isInlineXBRL",
      "isXBRL",
      "items",
      "primaryDocDescription",
      "primaryDocument",
      "reportDate",
      "size"
    ]

    Enum.zip(for name <- field_names, do: Map.get(filings, name))
    |> Enum.map(fn tuple -> Map.new(Enum.zip(field_names, Tuple.to_list(tuple))) end)
  end

  @doc """
  Parses form 3 and 3/A filing types from a given CIK and accession number

  ## Required

  * `cik` - The CIK of the entity
  * `accession_number` - The accession number of the filing
  """
  @spec form3_from_filing(cik :: String.t(), accession_number :: String.t()) ::
          success_type(map()) | error_type()
  def form3_from_filing(cik, accession_number), do: ownership_from_filing(cik, accession_number)

  @doc """
  Parses form 3 and 3/A ownership filing types from a given file path

  ## Required

  * `file_path` - The path of the form 3 filing to parse
  """
  @spec form3_from_file(file_path :: String.t()) :: success_type(map()) | error_type()
  def form3_from_file(file_path), do: ownership_form_from_file(file_path)

  @doc """
  Parses form 3 and 3/A ownership filing types from a given url

  ## Required

  * `url` - The url of the form 3 filing
  """
  @spec form3_from_url(url :: String.t()) :: success_type(map()) | error_type()
  def form3_from_url(url), do: ownership_form_from_url(url)

  @doc """
  Parses form 3 and 3/A ownership filing types from a given string

  ## Required

  * `xml_str` - The xml string of the form 3 filing to parse
  """
  @spec form3_from_string(xml_str :: String.t()) :: success_type(map()) | error_type()
  def form3_from_string(xml_str), do: ownership_form_from_string(xml_str)

  @doc """
  Parses form 4 and 4/A filing types from a given CIK and accession number

  ## Required

  * `cik` - The CIK of the entity
  * `accession_number` - The accession number of the filing
  """
  @spec form4_from_filing(cik :: String.t(), accession_number :: String.t()) ::
          success_type(map()) | error_type()
  def form4_from_filing(cik, accession_number), do: ownership_from_filing(cik, accession_number)

  @doc """
  Parses form 4 and 4/A ownership filing types from a given file path

  ## Required

  * `file_path` - The path of the form 4 filing to parse
  """
  @spec form4_from_file(file_path :: String.t()) :: success_type(map()) | error_type()
  def form4_from_file(file_path), do: ownership_form_from_file(file_path)

  @doc """
  Parses form 4 and 4/A ownership filing types from a given url

  ## Required

  * `url` - The url of the form 3 filing
  """
  @spec form4_from_url(url :: String.t()) :: success_type(map()) | error_type()
  def form4_from_url(url), do: ownership_form_from_url(url)

  @doc """
  Parses form 4 and 4/A ownership filing types from a given string

  ## Required

  * `xml_str` - The xml string of the form 4 filing to parse
  """
  @spec form4_from_string(xml_str :: String.t()) :: success_type(map()) | error_type()
  def form4_from_string(xml_str), do: ownership_form_from_string(xml_str)

  @doc """
  Parses form 5 and 5/A filing types from a given CIK and accession number

  ## Required

  * `cik` - The CIK of the entity
  * `accession_number` - The accession number of the filing
  """
  @spec form5_from_filing(cik :: String.t(), accession_number :: String.t()) ::
          success_type(map()) | error_type()
  def form5_from_filing(cik, accession_number), do: ownership_from_filing(cik, accession_number)

  @doc """
  Parses form 5 and 5/A ownership filing types from a given file path

  ## Required

  * `file_path` - The path of the form 5 filing to parse
  """
  @spec form5_from_file(file_path :: String.t()) :: success_type(map()) | error_type()
  def form5_from_file(file_path), do: ownership_form_from_file(file_path)

  @doc """
  Parses form 5 and 5/A ownership filing types from a given url

  ## Required

  * `url` - The url of the form 3 filing
  """
  @spec form5_from_url(url :: String.t()) :: success_type(map()) | error_type()
  def form5_from_url(url), do: ownership_form_from_url(url)

  @doc """
  Parses form 5 and 5/A ownership filing types from a given string

  ## Required

  * `xml_str` - The xml string of the form 5 filing to parse
  """
  @spec form5_from_string(xml_str :: String.t()) :: success_type(map()) | error_type()
  def form5_from_string(xml_str), do: ownership_form_from_string(xml_str)

  @doc """
  Parses form 3, 3/A, 4, 4/A, 5, and 5/A ownership filing types from a given CIK and accession number

  ## Required

  * `cik` - The CIK of the entity
  * `accession_number` - The accession number of the filing
  """
  @spec ownership_from_filing(cik :: String.t(), accession_number :: String.t()) ::
          success_type(map()) | error_type()
  def ownership_from_filing(cik, accession_number) do
    case filing_directory(cik, accession_number) do
      {:ok, dir} ->
        files = dir["directory"]["item"]

        case Enum.find(files, fn file -> String.ends_with?(file["name"], ".xml") end) do
          nil ->
            {:error, "No xml file found"}

          xml_file ->
            acc_no = String.replace(accession_number, "-", "")
            xml_file_url = "#{@edgar_archives_url}/data/#{cik}/#{acc_no}/#{xml_file["name"]}"

            ownership_form_from_url(xml_file_url)
        end

      error ->
        error
    end
  end

  @doc """
  Parses form 3, 3/A, 4, 4/A, 5, and 5/A ownership filing types from a file

  ## Required

  * `file_path` - The path to the file
  """
  @spec ownership_form_from_file(file_path :: String.t()) :: success_type(map()) | error_type()
  def ownership_form_from_file(file_path) do
    with {:ok, body} <- File.read(file_path), do: ownership_form_from_string(body)
  end

  @doc """
  Parses form 3, 3/A, 4, 4/A, 5, and 5/A ownership filing types from a given url

  ## Required

  * `url` - The url of the form 4 to parse
  """
  @spec ownership_form_from_url(url :: String.t()) :: success_type(map()) | error_type()
  def ownership_form_from_url(url) do
    with {:ok, body} <- get(url), do: ownership_form_from_string(body)
  end

  @doc """
  Parses form 3, 3/A, 4, 4/A, 5, and 5/A filing types from a string

  ## Required

  * `form_str` - The document string to parse
  """
  @spec ownership_form_from_string(form_str :: String.t()) :: success_type(map()) | error_type()
  def ownership_form_from_string(form_str), do: EDGAR.Native.parse_ownership_form(form_str)

  @doc """

  Parses a form 13F filing for a given CIK and accession number

  ## Required

  * `cik` - The CIK of the entity
  * `accession_number` - The accession number of the filing
  """
  @spec form13f_from_filing(cik :: String.t(), accession_number :: String.t()) ::
          success_type(map()) | error_type()
  def form13f_from_filing(cik, accession_number) do
    case filing_directory(cik, accession_number) do
      {:ok, dir} ->
        files = dir["directory"]["item"]

        primary_doc_file = Enum.find(files, fn file -> file["name"] == "primary_doc.xml" end)

        table_xml_file =
          Enum.find(files, fn file ->
            file["name"] != "primary_doc.xml" and String.ends_with?(file["name"], ".xml")
          end)

        if primary_doc_file && table_xml_file do
          formatted_acc_no = String.replace(accession_number, "-", "")

          primary_doc_url =
            "#{@edgar_archives_url}/data/#{cik}/#{formatted_acc_no}/#{primary_doc_file["name"]}"

          table_xml_url =
            "#{@edgar_archives_url}/data/#{cik}/#{formatted_acc_no}/#{table_xml_file["name"]}"

          with {:ok, document} <- form13f_document_from_url(primary_doc_url),
               {:ok, table} <- form13f_table_from_url(table_xml_url) do
            {:ok, %{document: document, table: table}}
          else
            error -> error
          end
        else
          {:error, "No primary_doc or table file found"}
        end

      error ->
        error
    end
  end

  @doc """
  Parses a form 13F document from a given file path

  ## Required

  * `file_path` - The path to the 13F document to parse
  """
  @spec form13f_document_from_file(file_path :: String.t()) :: success_type(map()) | error_type()
  def form13f_document_from_file(file_path) do
    with {:ok, body} <- File.read(file_path), do: form13f_document_from_string(body)
  end

  @doc """
  Parses a form 13F filing from a given url

  ## Required

  * `url` - The url of the form 13F document to parse
  """
  @spec form13f_document_from_url(url :: String.t()) :: success_type(map()) | error_type()
  def form13f_document_from_url(url) do
    with {:ok, body} <- get(url), do: form13f_document_from_string(body)
  end

  @doc """
  Parses a form 13F filing primary document from a string

  ## Required

  * `xml_str` - The document xml string to parse
  """
  @spec form13f_document_from_string(xml_str :: String.t()) :: success_type(map()) | error_type()
  def form13f_document_from_string(xml_str), do: EDGAR.Native.parse_form13f_document(xml_str)

  @doc """
  Parses a form 13F filing table from a file

  ## Required

  * `file_path` - The path to the 13F table file to parse
  """
  @spec form13f_table_from_file(file_path :: String.t()) :: success_type(map()) | error_type()
  def form13f_table_from_file(file_path) do
    with {:ok, body} <- File.read(file_path), do: form13f_table_from_string(body)
  end

  @doc """
  Parses a form 13F filing table from a given url

  ## Required

  * `url` - The url of the 13F table file to parse
  """
  @spec form13f_table_from_url(url :: String.t()) :: success_type(map()) | error_type()
  def form13f_table_from_url(url) do
    with {:ok, body} <- get(url), do: form13f_table_from_string(body)
  end

  @doc """
  Parses a form 13F filing table from a string

  ## Required

  * `xml_str` - The table xml string to parse
  """
  @spec form13f_table_from_string(xml_str :: String.t()) :: success_type(map()) | error_type()
  def form13f_table_from_string(xml_str), do: EDGAR.Native.parse_form13f_table(xml_str)

  @doc """
  Parses a xbrl filing file from a given file path

  ## Required

  * `file_path` - The path of the xbrl filing to parse
  """
  @spec xbrl_from_file(file_path :: String.t()) :: success_type(map()) | error_type()
  def xbrl_from_file(file_path) do
    with {:ok, file_content} <- File.read(file_path), do: xbrl_from_string(file_content)
  end

  @doc """
  Parses a xbrl filing from a given url

  ## Required

  * `url` - The url of the xbrl filing to parse
  """
  @spec xbrl_from_url(url :: String.t()) :: success_type(map()) | error_type()
  def xbrl_from_url(url) do
    with {:ok, body} <- get(url), do: xbrl_from_string(body)
  end

  @doc """
  Parses a XBRL file

  ## Required

  * `xbrl_str` - The XBRL xml string to parse
  """
  @spec xbrl_from_string(xbrl_str :: String.t()) :: success_type(map()) | error_type()
  def xbrl_from_string(xbrl_str), do: EDGAR.Native.parse_xbrl(xbrl_str)

  @doc """
  Fetches the current feed for a given CIK

  ## Optional

  * `CIK` - The CIK of the entity
  * `type` - The type of filing to filter by
  * `company` - The company to filter by
  * `dateb` - The date to filter by
  * `owner` - The owner to filter by
  * `start` - The start index of the filings to return
  * `count` - The number of filings to return
  """
  @spec current_feed(opts :: map()) :: success_type(map()) | error_type()
  def current_feed(opts \\ %{}) do
    opts = Map.merge(%{output: "atom"}, opts)
    url = "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&#{URI.encode_query(opts)}"
    with {:ok, body} <- get(url), do: current_feed_from_string(body)
  end

  @doc """
  Parses the current feed

  ## Required

  * `xml_str` - The RSS feed xml to parse
  """
  @spec current_feed_from_string(xml_str :: String.t()) :: success_type(map()) | error_type()
  def current_feed_from_string(xml_str), do: EDGAR.Native.parse_current_feed(xml_str)

  @doc """
  Fetches the company feed for a given CIK

  ## Required

  * `cik` - The CIK of the entity

  ## Optional

  * `type` - The type of filing to filter by
  * `start` - The start index of the filings to return
  * `count` - The number of filings to return
  """
  @spec company_feed(cik :: String.t(), opts :: map()) :: success_type(map()) | error_type()
  def company_feed(cik, opts \\ %{}) do
    opts = Map.merge(%{output: "atom", CIK: cik}, opts)
    url = "https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&#{URI.encode_query(opts)}"
    with {:ok, body} <- get(url), do: company_feed_from_string(body)
  end

  @doc """
  Parses the company feed

  ## Required

  * `xml_str` - The RSS feed xml string to parse
  """
  @spec company_feed_from_string(xml_str :: String.t()) :: success_type(map()) | error_type()
  def company_feed_from_string(xml_str), do: EDGAR.Native.parse_company_feed(xml_str)

  @doc """
  Fetches the press release feed
  """
  @spec press_release_feed :: success_type(map()) | error_type()
  def press_release_feed do
    url = "https://www.sec.gov/news/pressreleases.rss"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the speeches and statements feed
  """
  @spec speeches_and_statements_feed :: success_type(map()) | error_type()
  def speeches_and_statements_feed do
    url = "https://www.sec.gov/news/speeches-statements.rss"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the speeches feed
  """
  @spec speeches_feed :: success_type(map()) | error_type()
  def speeches_feed do
    url = "https://www.sec.gov/news/speeches.rss"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the statements feed
  """
  @spec statements_feed :: success_type(map()) | error_type()
  def statements_feed do
    url = "https://www.sec.gov/news/statements.rss"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the testimony feed
  """
  @spec testimony_feed :: success_type(map()) | error_type()
  def testimony_feed do
    url = "https://www.sec.gov/news/testimony.rss"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the litigation feed
  """
  @spec litigation_feed :: success_type(map()) | error_type()
  def litigation_feed do
    url = "https://www.sec.gov/rss/litigation/litreleases.xml"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the administrative proceedings feed
  """
  @spec administrative_proceedings_feed :: success_type(map()) | error_type()
  def administrative_proceedings_feed do
    url = "https://www.sec.gov/rss/litigation/admin.xml"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the trading suspensions feed
  """
  @spec trading_suspensions_feed :: success_type(map()) | error_type()
  def trading_suspensions_feed do
    url = "https://www.sec.gov/rss/litigation/suspensions.xml"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the division of corporation finance news feed
  """
  @spec division_of_corporation_finance_feed :: success_type(map()) | error_type()
  def division_of_corporation_finance_feed do
    url = "https://www.sec.gov/rss/divisions/corpfin/cfnew.xml"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the division of investment management news feed
  """
  @spec division_of_investment_management_feed :: success_type(map()) | error_type()
  def division_of_investment_management_feed do
    url = "https://www.sec.gov/rss/divisions/investment/imnews.xml"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Fetches the investor alerts feed
  """
  @spec investor_alerts_feed :: success_type(map()) | error_type()
  def investor_alerts_feed do
    url = "https://www.sec.gov/rss/investor/alerts"
    with {:ok, body} <- get(url), do: rss_feed_from_string(body)
  end

  @doc """
  Parses the press release feed from a string

  ## Required

  * `xml_str` - The RSS feed xml string to parse
  """
  @spec rss_feed_from_string(xml_str :: String.t()) :: success_type(map()) | error_type()
  def rss_feed_from_string(xml_str), do: EDGAR.Native.parse_rss_feed(xml_str)

  @doc """
  Fetches the recent filings rss feed
  """
  @spec filings_feed :: success_type(map()) | error_type()
  def filings_feed do
    url = "https://www.sec.gov/Archives/edgar/usgaap.rss.xml"
    with {:ok, body} <- get(url), do: filing_feed_from_string(body)
  end

  @doc """
  Fetch the recent mutual funds filings rss feed
  """
  @spec mutual_funds_feed :: success_type(map()) | error_type()
  def mutual_funds_feed do
    url = "https://www.sec.gov/Archives/edgar/xbrl-rr.rss.xml"
    with {:ok, body} <- get(url), do: filing_feed_from_string(body)
  end

  @doc """
  Fetches the recent XBRL rss feed
  """
  @spec xbrl_feed :: success_type(map()) | error_type()
  def xbrl_feed do
    url = "https://www.sec.gov/Archives/edgar/xbrlrss.all.xml"
    with {:ok, body} <- get(url), do: filing_feed_from_string(body)
  end

  @doc """
  Fetches the recent inline XBRL rss feed
  """
  @spec inline_xbrl_feed :: success_type(map()) | error_type()
  def inline_xbrl_feed do
    url = "https://www.sec.gov/Archives/edgar/xbrl-inline.rss.xml"
    with {:ok, body} <- get(url), do: filing_feed_from_string(body)
  end

  @doc """
  Fetches the historical XBRL feed for the given year and month

  ## Required

  * `year` - The year to fetch the XBRL feed for (must be 2005 or later)
  * `month` - The month to fetch the XBRL feed for
  """
  @spec historical_xbrl_feed(year :: integer(), month :: integer()) ::
          success_type(map()) | error_type()
  def historical_xbrl_feed(year, month) do
    case {year, month} do
      {year, _} when year < 2005 ->
        {:error, "year must be 2005 or later"}

      {_, month} when month < 1 or month > 12 ->
        {:error, "month must be between 1 and 12"}

      {year, month} ->
        formatted_month =
          month
          |> Integer.to_string()
          |> String.pad_leading(2, "0")

        url = "https://www.sec.gov/Archives/edgar/monthly/xbrlrss-#{year}-#{formatted_month}.xml"
        with {:ok, body} <- get(url), do: filing_feed_from_string(body)
    end
  end

  @doc """
  Parses a filing feed from a string

  ## Required

  * `xml_str` - The XBRL feed xml string to parse
  """
  @spec filing_feed_from_string(xml_str :: String.t()) :: success_type(map()) | error_type()
  def filing_feed_from_string(xml_str), do: EDGAR.Native.parse_filing_feed(xml_str)

  defp get(url) do
    SimpleRateLimiter.wait_and_proceed(fn ->
      user_agent =
        Application.get_env(:edgar_client, :user_agent, "default <default@default.com>")

      case Req.get(url, headers: [{"User-Agent", user_agent}], redirect_log_level: false) do
        {:ok, %Req.Response{status: 200, body: body}} ->
          {:ok, body}

        {:ok, %Req.Response{status: 404}} ->
          {:error, "resource not found"}

        {:ok, %Req.Response{status: code}} ->
          {:error, "unexpected status code: #{code}"}

        {:error, reason} ->
          {:error, reason}
      end
    end)
  end
end