lib/worker.ex

defmodule LocationSimulator.Worker do
  @moduledoc """
  Worker simulator that generates synthetic GPS data.

  Each worker picks a travel direction (or randomises one if not supplied),
  then emits GPS events at the configured interval until the event count is
  exhausted, the callback requests a stop, or a group-stop message arrives.

  See `LocationSimulator.Event` for the callback contract.
  """

  require Logger

  import LocationSimulator.Gps
  alias LocationSimulator.WorkerBase

  # ── OTP ────────────────────────────────────────────────────────────────────

  @spec start_link(map()) :: {:ok, pid()}
  def start_link(arg), do: WorkerBase.start_link(__MODULE__, arg)

  @spec child_spec(map()) :: map()
  def child_spec(opts), do: WorkerBase.child_spec(__MODULE__, opts)

  # ── Entry point ────────────────────────────────────────────────────────────

  @doc "Entry point called by `start_link/1`."
  @spec init(map()) :: :ok
  def init(config) do
    state = WorkerBase.init_state()
    config = resolve_direction(config)
    config = WorkerBase.fire_start!(config, state)

    :rand.seed(:exsss)

    gps = initial_gps(config)
    state = Map.put(state, :gps, gps)
    counter = Map.get(config, :event, :infinity)

    WorkerBase.maybe_register(config)

    Logger.debug(
      "Worker #{inspect(Map.get(config, :id))}: starting loop, events=#{inspect(counter)}"
    )

    loop(config, state, counter)
  end

  # ── Private: direction ─────────────────────────────────────────────────────

  @directions [:north, :south, :east, :west, :north_east, :north_west, :south_east, :south_west]

  # Explicit :random → pick one
  defp resolve_direction(%{direction: :random} = config) do
    Map.put(config, :direction, Enum.random(@directions))
  end

  # Already a specific direction → keep it
  defp resolve_direction(%{direction: _} = config), do: config
  # Key missing entirely → same as :random
  defp resolve_direction(config) do
    Map.put(config, :direction, Enum.random(@directions))
  end

  # ── Private: initial GPS ───────────────────────────────────────────────────

  defp initial_gps(%{started_gps: {lat, lon, ele}}) do
    %{timestamp: 0, lat: lat, lon: lon, ele: ele}
  end

  defp initial_gps(config) do
    {lat, lon} = generate_pos()
    ele = Map.get(config, :elevation, 0)
    %{timestamp: 0, lat: lat, lon: lon, ele: ele}
  end

  # ── Private: loop (terminal) ───────────────────────────────────────────────

  defp loop(config, state, 0) do
    WorkerBase.fire_stop!(config, state)
    :ok
  end

  # ── Private: loop (active) ─────────────────────────────────────────────────

  defp loop(%{id: id} = config, %{gps: last_gps} = state, counter) do
    sleep_ms = sleep_duration(config)
    Process.sleep(sleep_ms)

    new_gps = next_gps(config, last_gps, sleep_ms)
    state = Map.put(state, :gps, new_gps)

    {config, state, next_counter} = dispatch_event(config, state, counter)

    case next_counter do
      :stop ->
        loop(config, state, 0)

      :infinity ->
        receive do
          {:stop_worker, ^id} ->
            Logger.debug("Worker #{inspect(id)}: stop received from group manager")
            loop(config, state, 0)
        after
          0 ->
            loop(config, state, :infinity)
        end

      n ->
        receive do
          {:stop_worker, ^id} ->
            Logger.debug("Worker #{inspect(id)}: stop received from group manager")
            loop(config, state, 0)
        after
          0 ->
            loop(config, state, n - 1)
        end
    end
  end

  # ── Private: GPS generation ────────────────────────────────────────────────

  defp next_gps(config, last, sleep_ms) do
    # direction is guaranteed present after resolve_direction/1
    direction = Map.get(config, :direction, :north)

    {lat, lon} =
      generate_next_pos(
        last.lat,
        last.lon,
        lat_step(direction),
        lon_step(direction)
      )

    ele = next_elevation(Map.get(config, :elevation_way, :no_up_down), last.ele)

    %{timestamp: last.timestamp + sleep_ms, lat: lat, lon: lon, ele: ele}
  end

  defp next_elevation(:up, ele), do: ele + Enum.random(0..2)
  defp next_elevation(:down, ele), do: ele - Enum.random(0..2)
  defp next_elevation(_, ele), do: ele

  # ── Private: step helpers ──────────────────────────────────────────────────

  # Longitude step (east/west component)
  defp lon_step(dir) do
    case dir do
      d when d in [:north_east, :south_east, :east] -> Enum.random(1..5)
      d when d in [:north_west, :south_west, :west] -> Enum.random(-5..-1//1)
      _ -> 0
    end
  end

  # Latitude step (north/south component)
  defp lat_step(dir) do
    case dir do
      d when d in [:north_east, :north_west, :north] -> Enum.random(1..5)
      d when d in [:south_east, :south_west, :south] -> Enum.random(-5..-1//1)
      _ -> 0
    end
  end

  # ── Private: event dispatch ────────────────────────────────────────────────

  defp dispatch_event(%{callback: mod} = config, state, counter) do
    case mod.event(config, state) do
      {:ok, new_config} -> {new_config, Map.update!(state, :success, &(&1 + 1)), counter}
      {:error, _reason} -> {config, Map.update!(state, :failed, &(&1 + 1)), counter}
      {:stop, _reason} -> {config, state, :stop}
    end
  end

  defp sleep_duration(%{interval: interval, random_range: range}) do
    interval + Enum.random(0..range)
  end

  defp sleep_duration(%{interval: interval}), do: interval
end