defmodule TeslaMate.Terrain do
  use GenStateMachine

  require Logger
  import Core.Dependency, only: [call: 3]
  alias TeslaMate.Log.Position
  alias TeslaMate.Log

  defstruct [:timeout, :deps, :name]
  alias __MODULE__, as: Data

  @name __MODULE__

  # API

  def start_link(opts) do
    GenStateMachine.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, @name))
  end

  def get_elevation(name \\ @name, coordinates) do
    GenStateMachine.call(name, {:get_elevation, coordinates}, 2000)
  end

  # Callbacks

  @impl true
  def init(opts) do
    data = %Data{
      timeout: Keyword.get(opts, :timeout, 100),
      name: Keyword.get(opts, :name, @name),
      deps: %{
        srtm: Keyword.get(opts, :deps_srtm, SRTM),
        log: Keyword.get(opts, :deps_log, Log)
      }
    }

    case Keyword.get(opts, :disabled, false) do
      false ->
        {:ok, :ready, data, {:next_event, :internal, {:fetch_positions, 0}}}

      true ->
        {:ok, :disabled, data}
    end
  end

  ## Call

  @impl true
  def handle_event({:call, from}, {:get_elevation, {lat, lng}}, :ready, %Data{} = data) do
    task = Task.async(fn -> do_get_elevation({lat, lng}, data) end)

    case Task.yield(task, data.timeout) do
      {:ok, {:ok, elevation}} ->
        {:keep_state_and_data, {:reply, from, elevation}}

      {:ok, {:error, :unavailable}} ->
        {:keep_state_and_data, {:reply, from, nil}}

      {:ok, {:error, reason}} ->
        log_warning(reason)
        {:keep_state_and_data, {:reply, from, nil}}

      nil ->
        Logger.info("Querying location for elevation takes longer than #{data.timeout}ms ...")
        {:next_state, {:waiting, task.ref}, data, {:reply, from, nil}}
    end
  end

  def handle_event({:call, from}, {:get_elevation, _coords}, _state, _data) do
    {:keep_state_and_data, {:reply, from, nil}}
  end

  ## Internal

  def handle_event(event, {:fetch_positions, min_id}, :ready, %Data{} = data)
      when event in [:internal, :state_timeout] do
    case call(data.deps.log, :get_positions_without_elevation, [min_id, [limit: 1000]]) do
      {[], nil} ->
        {:keep_state_and_data, schedule_fetch()}

      {positions, next} ->
        Logger.info("Adding elevation to #{length(positions)} positions ...")

        :ok = GenStateMachine.cast(self(), :process)

        {:next_state, {:update, positions, next, nil}, data}
    end
  end

  ## Cast

  def handle_event(:cast, :process, {:update, [], next, nil}, data) do
    {:next_state, :ready, data, {:next_event, :internal, {:fetch_positions, next}}}
  end

  def handle_event(:cast, :process, {:update, [%Position{} = p | rest], next, nil}, data) do
    task =
      Task.async(fn ->
        do_get_elevation(
          {
            Decimal.to_float(p.latitude),
            Decimal.to_float(p.longitude)
          },
          data
        )
      end)

    case Task.yield(task, data.timeout) do
      {:ok, {:ok, elevation}} ->
        {:ok, _pos} = call(data.deps.log, :update_position, [p, %{elevation: elevation}])
        :ok = GenStateMachine.cast(self(), :process)
        {:next_state, {:update, rest, next, nil}, data}

      {:ok, {:error, :unavailable}} ->
        :ok = GenStateMachine.cast(self(), :process)
        {:next_state, {:update, rest, next, nil}, data}

      {:ok, {:error, reason}} ->
        log_warning(reason)
        :ok = GenStateMachine.cast(self(), :process)
        {:next_state, {:update, rest, next, nil}, data}

      nil ->
        Logger.info("Querying location for elevation takes longer than #{data.timeout}ms ...")
        {:next_state, {:update, [p | rest], next, task.ref}, data}
    end
  end

  ## Info

  def handle_event(:info, {ref, result}, {:waiting, ref}, data) do
    case result do
      {:ok, elevation} ->
        Logger.debug("Received delayed SRTM message: #{elevation}m")
        {:next_state, :ready, data, schedule_fetch()}

      {:error, reason} ->
        log_warning(reason)
        {:next_state, :ready, data, schedule_fetch()}
    end
  end

  def handle_event(:info, {ref, result}, {:update, [%Position{} = p | rest], next, ref}, data) do
    case result do
      {:ok, elevation} ->
        Logger.debug("Received delayed SRTM message: #{elevation}m")
        {:ok, _pos} = call(data.deps.log, :update_position, [p, %{elevation: elevation}])
        :ok = GenStateMachine.cast(self(), :process)
        {:next_state, {:update, rest, next, nil}, data}

      {:error, reason} ->
        log_warning(reason)
        :ok = GenStateMachine.cast(self(), :process)
        {:next_state, {:update, rest, next, nil}, data}
    end
  end

  def handle_event(:info, {:DOWN, _ref, :process, _pid, :normal}, _state, _data) do
    :keep_state_and_data
  end

  def handle_event(:info, :garbage_collect, _state, _data) do
    :erlang.garbage_collect(self())
    :keep_state_and_data
  end

  # Private

  defp do_get_elevation({lat, lng}, %Data{deps: %{srtm: srtm}, name: name} = data) do
    case :fuse.ask(name, :sync) do
      :ok ->
        with {:error, reason} <-
               call(srtm, :get_elevation, [lat, lng, [disk_cache_path: cache_path()]]) do
          :fuse.melt(name)
          {:error, reason}
        end

      :blown ->
        {:error, :unavailable}

      {:error, :not_found} ->
        Logger.debug("Installing circuit-breaker #{inspect(name)} ...")
        :fuse.install(name, {{:standard, 2, :timer.minutes(3)}, {:reset, :timer.minutes(15)}})
        do_get_elevation({lat, lng}, data)
    end
  end

  defp schedule_fetch do
    {:state_timeout, :timer.hours(6), {:fetch_positions, 0}}
  end

  defp log_warning(reason) do
    Logger.warning("Elevation query failed: #{inspect(reason)}")
  end

  defp cache_path do
    Application.fetch_env!(:teslamate, :srtm_cache)
  end
end
