defmodule Microwaveprop.Qrz.Client do
  @moduledoc """
  HTTP client for the QRZ.com XML callsign API.

  Maintains a session key in an `Agent` and transparently re-logs-in
  when the upstream returns a `session_expired` response. The session
  key is cached process-local because QRZ rate-limits login calls.
  """
  use Agent

  require Record

  Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl"))
  Record.defrecord(:xmlText, Record.extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl"))

  @url "https://xmldata.qrz.com/xml/current/"

  @spec start_link(keyword()) :: Agent.on_start()
  def start_link(_) do
    Agent.start_link(fn -> nil end, name: __MODULE__)
  end

  @spec reset_session() :: :ok
  def reset_session do
    Agent.update(__MODULE__, fn _ -> nil end)
  end

  @spec login() :: {:ok, String.t()} | {:error, String.t()}
  def login do
    config = Application.get_env(:microwaveprop, __MODULE__, [])
    username = Keyword.fetch!(config, :username)
    password = Keyword.fetch!(config, :password)
    agent = Keyword.get(config, :agent, "microwaveprop/1.0")

    params = %{username: username, password: password, agent: agent}

    with {:ok, xml} <- do_request(params),
         {:ok, key} <- parse_session(xml) do
      Agent.update(__MODULE__, fn _ -> key end)
      {:ok, key}
    end
  end

  @spec lookup(String.t()) :: {:ok, map()} | {:error, String.t()}
  def lookup(callsign) do
    with {:ok, key} <- ensure_session() do
      do_lookup(callsign, key, true)
    end
  end

  defp ensure_session do
    case Agent.get(__MODULE__, & &1) do
      nil -> login()
      key -> {:ok, key}
    end
  end

  defp do_lookup(callsign, key, retry?) do
    params = %{s: key, callsign: callsign}

    with {:ok, xml} <- do_request(params) do
      xml
      |> parse_callsign_response()
      |> handle_lookup_result(callsign, retry?)
    end
  end

  defp handle_lookup_result({:ok, _} = success, _, _), do: success

  defp handle_lookup_result({:error, :session_expired}, callsign, true) do
    Agent.update(__MODULE__, fn _ -> nil end)

    with {:ok, new_key} <- login() do
      do_lookup(callsign, new_key, false)
    end
  end

  defp handle_lookup_result({:error, :session_expired}, _, false) do
    {:error, "Session expired after retry"}
  end

  defp handle_lookup_result({:error, _} = error, _, _), do: error

  defp do_request(params) do
    Microwaveprop.Instrument.span([:qrz, :request], %{}, fn ->
      config = Application.get_env(:microwaveprop, __MODULE__, [])

      req_opts =
        [url: @url, params: params]
        |> maybe_add_plug(config)
        |> maybe_add_retry(config)

      case Req.get(req_opts) do
        {:ok, %{status: 200, body: body}} ->
          {:ok, body}

        {:ok, %{status: status}} ->
          {:error, "HTTP #{status}"}

        {:error, reason} ->
          {:error, "Request failed: #{inspect(reason)}"}
      end
    end)
  end

  defp maybe_add_plug(opts, config) do
    case Keyword.get(config, :plug) do
      nil -> opts
      plug -> Keyword.put(opts, :plug, plug)
    end
  end

  defp maybe_add_retry(opts, config) do
    case Keyword.get(config, :retry) do
      nil -> opts
      retry -> Keyword.put(opts, :retry, retry)
    end
  end

  defp parse_session(xml) do
    bytes = :binary.bin_to_list(xml)
    {doc, _} = :xmerl_scan.string(bytes)

    case xpath_text(doc, ~c"//Session/Key") do
      nil ->
        error = xpath_text(doc, ~c"//Session/Error") || "Unknown error"
        {:error, error}

      key ->
        {:ok, key}
    end
  end

  defp parse_callsign_response(xml) do
    bytes = :binary.bin_to_list(xml)
    {doc, _} = :xmerl_scan.string(bytes)

    callsign_elements = :xmerl_xpath.string(~c"//Callsign/*", doc)

    if callsign_elements == [] do
      case xpath_text(doc, ~c"//Session/Key") do
        nil ->
          {:error, :session_expired}

        _ ->
          error = xpath_text(doc, ~c"//Session/Error") || "Not found"
          {:error, error}
      end
    else
      data =
        callsign_elements
        |> Enum.filter(&Record.is_record(&1, :xmlElement))
        |> Map.new(fn elem ->
          name =
            elem
            |> xmlElement(:name)
            |> to_string()

          value = extract_text(elem)
          {name, value}
        end)

      {:ok, data}
    end
  end

  defp xpath_text(doc, path) do
    case :xmerl_xpath.string(path, doc) do
      [] ->
        nil

      [element | _] ->
        extract_text(element)
    end
  end

  defp extract_text(element) do
    element
    |> xmlElement(:content)
    |> Enum.filter(&Record.is_record(&1, :xmlText))
    |> Enum.map_join(fn text_node ->
      text_node
      |> xmlText(:value)
      |> to_string()
    end)
    |> then(fn
      "" -> nil
      text -> text
    end)
  end
end
