defmodule WandererApp.Map.Operations.Systems do @moduledoc """ CRUD and batch upsert for map systems. """ alias WandererApp.MapSystemRepo alias WandererApp.Map.Server alias WandererApp.Map.Operations.Connections require Logger @spec list_systems(String.t()) :: [map()] def list_systems(map_id) do with {:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do systems else _ -> [] end end @spec get_system(String.t(), integer()) :: {:ok, map()} | {:error, :not_found} def get_system(map_id, system_id) do MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) end @spec create_system(Plug.Conn.t(), map()) :: {:ok, map()} | {:error, atom()} def create_system( %{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, params ) do do_create_system(map_id, user_id, char_id, params) end def create_system(_conn, _params), do: {:error, :missing_params} # Private helper for batch upsert defp create_system_batch(%{map_id: map_id, user_id: user_id, char_id: char_id}, params) do with {:ok, solar_system_id} <- fetch_system_id(params) do # Default to true so re-submitting with new position updates the system update_existing = fetch_update_existing(params, true) map_id |> WandererApp.Map.check_location(%{solar_system_id: solar_system_id}) |> case do {:ok, _location} -> do_create_system(map_id, user_id, char_id, params) {:error, :already_exists} -> if update_existing do # Mark as skip so it counts as "updated" not "created" case do_update_system(map_id, user_id, char_id, solar_system_id, params) do {:ok, _} -> {:skip, :updated} error -> error end else {:skip, :already_exists} end end end end defp do_create_system(map_id, user_id, char_id, params) do with {:ok, system_id} <- fetch_system_id(params), update_existing <- fetch_update_existing(params, false), coords <- normalize_coordinates(params), :ok <- Server.add_system( map_id, %{solar_system_id: system_id, coordinates: coords, extra: params}, user_id, char_id, update_existing: update_existing ) do # System creation is async, but if add_system returns :ok, # it means the operation was queued successfully {:ok, %{solar_system_id: system_id}} else {:error, reason} when is_binary(reason) -> Logger.warning("[do_create_system] Expected error: #{inspect(reason)}") {:error, :expected_error} error -> Logger.error("[do_create_system] Unexpected error: #{inspect(error)}") {:error, :unexpected_error} end end @spec update_system(Plug.Conn.t(), integer(), map()) :: {:ok, map()} | {:error, atom()} def update_system( %{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, solar_system_id, attrs ) do do_update_system(map_id, user_id, char_id, solar_system_id, attrs) end def update_system(_conn, _solar_system_id, _attrs), do: {:error, :missing_params} defp do_update_system(map_id, _user_id, _char_id, solar_system_id, params) do with {:ok, current} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id), x_raw <- Map.get(params, "position_x", Map.get(params, :position_x, current.position_x)), y_raw <- Map.get(params, "position_y", Map.get(params, :position_y, current.position_y)), {:ok, x} <- parse_int(x_raw, "position_x"), {:ok, y} <- parse_int(y_raw, "position_y"), coords = %{x: x, y: y}, :ok <- apply_system_updates(map_id, solar_system_id, params, coords), {:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do {:ok, system} else {:error, reason} when is_binary(reason) -> Logger.warning("[update_system] Expected error: #{inspect(reason)}") {:error, :expected_error} error -> Logger.error("[update_system] Unexpected error: #{inspect(error)}") {:error, :unexpected_error} end end @spec delete_system(Plug.Conn.t(), integer()) :: {:ok, integer()} | {:error, atom()} def delete_system( %{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, system_id ) do with {:ok, _} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id), :ok <- Server.delete_systems(map_id, [system_id], user_id, char_id) do {:ok, 1} else {:error, :not_found} -> Logger.warning("[delete_system] System not found: #{inspect(system_id)}") {:error, :not_found} _ -> Logger.error("[delete_system] Unexpected error") {:error, :unexpected_error} end end def delete_system(_conn, _system_id), do: {:error, :missing_params} @spec upsert_systems_and_connections(Plug.Conn.t(), [map()], [map()]) :: {:ok, map()} | {:error, atom()} def upsert_systems_and_connections( %{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = conn, systems, connections ) do assigns = %{map_id: map_id, user_id: user_id, char_id: char_id} {created_s, updated_s, _skipped_s} = upsert_each(systems, fn sys -> create_system_batch(assigns, sys) end, 0, 0, 0) conn_results = connections |> Enum.reduce(%{created: 0, updated: 0, skipped: 0}, fn conn_data, acc -> case Connections.upsert_single(conn, conn_data) do {:ok, :created} -> %{acc | created: acc.created + 1} {:ok, :updated} -> %{acc | updated: acc.updated + 1} _ -> %{acc | skipped: acc.skipped + 1} end end) {:ok, %{ systems: %{created: created_s, updated: updated_s}, connections: %{created: conn_results.created, updated: conn_results.updated} }} end def upsert_systems_and_connections(_conn, _systems, _connections), do: {:error, :missing_params} # -- Internal Helpers ------------------------------------------------------- defp fetch_system_id(%{"solar_system_id" => id}), do: parse_int(id, "solar_system_id") defp fetch_system_id(%{solar_system_id: id}) when not is_nil(id), do: parse_int(id, "solar_system_id") defp fetch_system_id(_), do: {:error, "Missing system identifier (id)"} defp fetch_update_existing(%{"update_existing" => update_existing}, _default), do: update_existing defp fetch_update_existing(%{update_existing: update_existing}, _default) when not is_nil(update_existing), do: update_existing defp fetch_update_existing(_, default), do: default defp parse_int(val, _field) when is_integer(val), do: {:ok, val} defp parse_int(val, _field) when is_float(val), do: {:ok, trunc(val)} defp parse_int(val, field) when is_binary(val) do case Integer.parse(val) do {i, _} -> {:ok, i} _ -> {:error, "Invalid #{field}: #{val}"} end end defp parse_int(nil, field), do: {:error, "Missing #{field}"} defp parse_int(val, field), do: {:error, "Invalid #{field} type: #{inspect(val)}"} defp normalize_coordinates(%{"coordinates" => %{"x" => x, "y" => y}}) when is_number(x) and is_number(y), do: %{"x" => x, "y" => y} defp normalize_coordinates(%{coordinates: %{x: x, y: y}}) when is_number(x) and is_number(y), do: %{"x" => x, "y" => y} defp normalize_coordinates(params) do x = params |> Map.get("position_x", Map.get(params, :position_x)) y = params |> Map.get("position_y", Map.get(params, :position_y)) # Only return coordinates if both x and y are provided # Otherwise return nil to let the server use auto-positioning if is_number(x) and is_number(y) do %{"x" => x, "y" => y} else nil end end defp apply_system_updates(map_id, system_id, attrs, %{x: x, y: y}) do with :ok <- Server.update_system_position(map_id, %{ solar_system_id: system_id, position_x: round(x), position_y: round(y) }) do attrs |> Map.drop([ :coordinates, :position_x, :position_y, :solar_system_id, "coordinates", "position_x", "position_y", "solar_system_id" ]) |> Enum.reduce_while(:ok, fn {key, val}, _ -> case update_system_field(map_id, system_id, to_string(key), val) do :ok -> {:cont, :ok} err -> {:halt, err} end end) end end defp update_system_field(map_id, system_id, field, val) do case field do "status" -> Server.update_system_status(map_id, %{ solar_system_id: system_id, status: convert_status(val) }) "description" -> Server.update_system_description(map_id, %{solar_system_id: system_id, description: val}) "tag" -> Server.update_system_tag(map_id, %{solar_system_id: system_id, tag: val}) "locked" -> bool = val in [true, "true", 1, "1"] Server.update_system_locked(map_id, %{solar_system_id: system_id, locked: bool}) f when f in ["label", "labels"] -> labels = cond do is_list(val) -> val is_binary(val) -> String.split(val, ",", trim: true) true -> [] end Server.update_system_labels(map_id, %{ solar_system_id: system_id, labels: Enum.join(labels, ",") }) "custom_name" -> Server.update_system_custom_name(map_id, %{ solar_system_id: system_id, custom_name: val }) "temporary_name" -> Server.update_system_temporary_name(map_id, %{ solar_system_id: system_id, temporary_name: val }) _ -> :ok end end defp convert_status("CLEAR"), do: 0 defp convert_status("DANGEROUS"), do: 1 defp convert_status("OCCUPIED"), do: 2 defp convert_status("MASS_CRITICAL"), do: 3 defp convert_status("TIME_CRITICAL"), do: 4 defp convert_status("REINFORCED"), do: 5 defp convert_status(i) when is_integer(i), do: i defp convert_status(s) when is_binary(s) do case Integer.parse(s) do {i, _} -> i _ -> 0 end end defp convert_status(_), do: 0 defp upsert_each([], _fun, c, u, d), do: {c, u, d} defp upsert_each([item | rest], fun, c, u, d) do case fun.(item) do {:ok, _} -> upsert_each(rest, fun, c + 1, u, d) :ok -> upsert_each(rest, fun, c + 1, u, d) {:skip, _} -> upsert_each(rest, fun, c, u + 1, d) _ -> upsert_each(rest, fun, c, u, d + 1) end end end