defmodule CobblemonUi.EvolutionApi do @moduledoc """ Fetches and caches Pokémon evolution data from PokeAPI. Provides `get_evolutions/1` which returns the list of species that a given Pokémon can evolve into (direct next-stage evolutions). Results are cached in an ETS table to avoid repeated API calls. """ use GenServer require Logger @table :evolution_cache # --------------------------------------------------------------------------- # Client API # --------------------------------------------------------------------------- def start_link(_opts) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @doc """ Returns a list of species names (lowercase strings) that the given species evolves into. Returns an empty list if there are no evolutions or the lookup fails. Results are cached after the first successful fetch. """ @spec get_evolutions(String.t()) :: [String.t()] def get_evolutions(species) when is_binary(species) do key = String.downcase(species) case :ets.lookup(@table, key) do [{^key, evolutions}] -> evolutions [] -> GenServer.call(__MODULE__, {:fetch, key}, 15_000) end end # --------------------------------------------------------------------------- # Server callbacks # --------------------------------------------------------------------------- @impl true def init(_opts) do :ets.new(@table, [:named_table, :set, :public, read_concurrency: true]) {:ok, %{}} end @impl true def handle_call({:fetch, species}, _from, state) do # Double-check cache (another caller may have populated it) result = case :ets.lookup(@table, species) do [{^species, evolutions}] -> evolutions [] -> evolutions = fetch_evolutions(species) :ets.insert(@table, {species, evolutions}) evolutions end {:reply, result, state} end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- defp fetch_evolutions(species) do with {:ok, chain_url} <- fetch_species_chain_url(species), {:ok, chain} <- fetch_chain(chain_url) do find_next_evolutions(chain, species) else _ -> [] end end defp fetch_species_chain_url(species) do url = "https://pokeapi.co/api/v2/pokemon-species/#{species}" case Req.get(url) do {:ok, %Req.Response{status: 200, body: %{"evolution_chain" => %{"url" => url}}}} -> {:ok, url} {:ok, %Req.Response{status: status}} -> Logger.warning("[EvolutionApi] Species lookup failed for #{species}: HTTP #{status}") {:error, :not_found} {:error, reason} -> Logger.warning("[EvolutionApi] Species lookup failed for #{species}: #{inspect(reason)}") {:error, reason} end end defp fetch_chain(url) do case Req.get(url) do {:ok, %Req.Response{status: 200, body: %{"chain" => chain}}} -> {:ok, chain} {:ok, %Req.Response{status: status}} -> Logger.warning("[EvolutionApi] Chain fetch failed: HTTP #{status}") {:error, :not_found} {:error, reason} -> Logger.warning("[EvolutionApi] Chain fetch failed: #{inspect(reason)}") {:error, reason} end end @doc false # Walks the evolution chain tree and returns the species names of # the direct next-stage evolutions for the given species. def find_next_evolutions(chain, target_species) do target = String.downcase(target_species) do_find(chain, target) end defp do_find(%{"species" => %{"name" => name}, "evolves_to" => evolves_to}, target) do if String.downcase(name) == target do # Found the target — return names of its direct evolutions Enum.map(evolves_to, fn evo -> get_in(evo, ["species", "name"]) |> String.downcase() end) else # Recurse into each branch Enum.find_value(evolves_to, [], fn evo -> case do_find(evo, target) do [] -> nil result -> result end end) end end defp do_find(_, _target), do: [] end