defmodule Microwaveprop.Buildings.Index do
  @moduledoc """
  In-memory spatial index over Microsoft building footprint records.
  Buckets buildings into a ~1.1 km grid so that a max-height query in
  a small radius scans only the 9 nearest buckets.

  Records (`Microwaveprop.Buildings.Parser.record`) are bucketed by
  centroid; queries widen by the building's `max_radius_m` so a long
  warehouse straddling two buckets is still found by points in either.

  Loading is one-shot per tile (idempotent via the `:loaded_tiles`
  bucket). Queries hit ETS directly with no GenServer involvement.
  """

  use GenServer

  alias Microwaveprop.Buildings.Parser

  @table :buildings_spatial_index
  @loaded_tiles_table :buildings_loaded_tiles
  # 0.01° ≈ 1.1 km at our latitude — strikes a balance between
  # bucket scan size and number of empty buckets allocated.
  @bucket_step 0.01

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  @impl true
  def init(_opts) do
    ensure_tables()
    {:ok, %{}}
  end

  @doc """
  Returns the list of building records within `radius_m` of `(lat, lon)`.
  Same scan path as `max_height_near/3` — useful for surfacing the
  individual obstacles to a UI layer.
  """
  @spec records_near(float(), float(), number()) :: [Parser.record()]
  def records_near(lat, lon, radius_m) do
    ensure_tables()
    radius_deg = radius_m / 111_000.0
    cos_lat = :math.cos(lat * :math.pi() / 180.0)

    {min_bx, max_bx} =
      bucket_range(lon - radius_deg / max(cos_lat, 0.1), lon + radius_deg / max(cos_lat, 0.1))

    {min_by, max_by} = bucket_range(lat - radius_deg, lat + radius_deg)

    for by <- min_by..max_by,
        bx <- min_bx..max_bx,
        {_, list} <- :ets.lookup(@table, {bx, by}),
        rec <- list,
        within?(rec, lat, lon, cos_lat, radius_m) do
      rec
    end
  end

  defp within?(rec, lat, lon, cos_lat, radius_m) do
    dlat_m = (rec.centroid_lat - lat) * 111_000.0
    dlon_m = (rec.centroid_lon - lon) * 111_000.0 * cos_lat
    dist_m = :math.sqrt(dlat_m * dlat_m + dlon_m * dlon_m)
    dist_m - rec.max_radius_m <= radius_m
  end

  @doc """
  Returns the maximum building `height_m` within `radius_m` of
  `(lat, lon)`. `0.0` if no buildings indexed within the radius.
  """
  @spec max_height_near(float(), float(), number()) :: float()
  def max_height_near(lat, lon, radius_m) do
    ensure_tables()
    radius_deg = radius_m / 111_000.0
    cos_lat = :math.cos(lat * :math.pi() / 180.0)

    {min_bx, max_bx} = bucket_range(lon - radius_deg / max(cos_lat, 0.1), lon + radius_deg / max(cos_lat, 0.1))
    {min_by, max_by} = bucket_range(lat - radius_deg, lat + radius_deg)

    for by <- min_by..max_by,
        bx <- min_bx..max_bx,
        {_, list} <- :ets.lookup(@table, {bx, by}),
        reduce: 0.0 do
      acc -> max(acc, scan_bucket(list, lat, lon, cos_lat, radius_m))
    end
  end

  defp scan_bucket(list, lat, lon, cos_lat, radius_m) do
    Enum.reduce(list, 0.0, fn rec, acc ->
      dlat_m = (rec.centroid_lat - lat) * 111_000.0
      dlon_m = (rec.centroid_lon - lon) * 111_000.0 * cos_lat
      dist_m = :math.sqrt(dlat_m * dlat_m + dlon_m * dlon_m)

      if dist_m - rec.max_radius_m <= radius_m,
        do: max(acc, rec.height_m),
        else: acc
    end)
  end

  @doc """
  Insert a list of `Parser.record/0` maps into the spatial index.
  Idempotent at the tile level (callers gate via `loaded?/1`); within
  a tile, inserting the same record twice will produce a duplicate
  entry but will not affect query correctness.
  """
  @spec insert_records([Parser.record()]) :: :ok
  def insert_records(records) when is_list(records) do
    ensure_tables()

    records
    |> Enum.group_by(fn r -> {bucket_x(r.centroid_lon), bucket_y(r.centroid_lat)} end)
    |> Enum.each(fn {key, recs} ->
      existing =
        case :ets.lookup(@table, key) do
          [{_, list}] -> list
          [] -> []
        end

      :ets.insert(@table, {key, recs ++ existing})
    end)

    :ok
  end

  @doc "Mark a quadkey as loaded so callers can skip re-parsing its tile."
  @spec mark_loaded(String.t()) :: :ok
  def mark_loaded(quadkey) do
    ensure_tables()
    :ets.insert(@loaded_tiles_table, {quadkey, true})
    :ok
  end

  @spec loaded?(String.t()) :: boolean()
  def loaded?(quadkey) do
    ensure_tables()
    :ets.member(@loaded_tiles_table, quadkey)
  end

  @doc "Wipe all index state (test helper / dev convenience)."
  @spec clear() :: :ok
  def clear do
    ensure_tables()
    :ets.delete_all_objects(@table)
    :ets.delete_all_objects(@loaded_tiles_table)
    :ok
  end

  defp ensure_tables do
    if :ets.whereis(@table) == :undefined do
      :ets.new(@table, [:set, :named_table, :public, read_concurrency: true])
    end

    if :ets.whereis(@loaded_tiles_table) == :undefined do
      :ets.new(@loaded_tiles_table, [:set, :named_table, :public, read_concurrency: true])
    end
  end

  defp bucket_range(min, max) do
    {bucket_index(min), bucket_index(max)}
  end

  defp bucket_x(lon), do: bucket_index(lon)
  defp bucket_y(lat), do: bucket_index(lat)
  defp bucket_index(v), do: trunc(Float.floor(v / @bucket_step))
end
