defmodule Microwaveprop.Beacons.Beacon do
  @moduledoc """
  A beacon transmitter. Frequency in MHz, coordinates as lat/lon,
  grid as Maidenhead (auto-computed from lat/lon when not supplied),
  power in milliwatts, height above ground in feet.
  """

  use Ecto.Schema

  import Ecto.Changeset

  alias Microwaveprop.Radio.Maidenhead

  @keying_entries [
    {"on_off", "On/Off"},
    {"fsk", "FSK"},
    {"fm_voice", "FM Voice"},
    {"wspr", "WSPR"},
    {"q65a_15", "Q65A-15"},
    {"q65a_30", "Q65A-30"},
    {"q65a_60", "Q65A-60"},
    {"q65a_120", "Q65A-120"},
    {"q65b_15", "Q65B-15"},
    {"q65b_30", "Q65B-30"},
    {"q65b_60", "Q65B-60"},
    {"q65b_120", "Q65B-120"},
    {"q65c_15", "Q65C-15"},
    {"q65c_30", "Q65C-30"},
    {"q65c_60", "Q65C-60"},
    {"q65c_120", "Q65C-120"},
    {"q65d_15", "Q65D-15"},
    {"q65d_30", "Q65D-30"},
    {"q65d_60", "Q65D-60"},
    {"q65d_120", "Q65D-120"},
    {"q65e_15", "Q65E-15"},
    {"q65e_30", "Q65E-30"},
    {"q65e_60", "Q65E-60"},
    {"q65e_120", "Q65E-120"}
  ]

  @keyings Enum.map(@keying_entries, fn {k, _} -> k end)
  @keying_labels Map.new(@keying_entries)

  @spec keyings() :: [String.t()]
  def keyings, do: @keyings

  @spec keying_label(String.t()) :: String.t()
  def keying_label(key), do: Map.get(@keying_labels, key, key)

  @doc """
  Formats a milliwatt power value as a plain decimal string, never scientific
  notation. Integers are printed without a decimal; floats keep up to three
  decimals with trailing zeros trimmed.
  """
  @spec format_mw(number() | nil | term()) :: String.t()
  def format_mw(nil), do: ""

  def format_mw(mw) when is_number(mw) do
    format_number(mw)
  end

  def format_mw(mw), do: to_string(mw)

  @doc "Formats a frequency in MHz with comma separators (e.g. 10368 → \"10,368\")."
  @spec format_freq(number() | nil | term()) :: String.t()
  def format_freq(nil), do: ""

  def format_freq(mhz) when is_number(mhz) do
    format_number(mhz)
  end

  def format_freq(mhz), do: to_string(mhz)

  # Shared formatter for format_mw/1 and format_freq/1. Unconditionally
  # renders to 3 decimal places, trims trailing zeros, and handles the
  # edge case where `trim_trailing_zeros/1` strips the decimal point
  # entirely (e.g. 24192.0 → "24192.000" → "24192", which previously
  # crashed the `[_int, frac_part] = ...` pattern match when
  # `frac != 0.0` due to float rounding error).
  defp format_number(n) when is_integer(n), do: add_commas(n)

  defp format_number(n) when is_float(n) do
    formatted_int = n |> trunc() |> add_commas()
    decimal = n |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()

    case String.split(decimal, ".") do
      [_int_only] -> formatted_int
      [_int, frac_part] -> "#{formatted_int}.#{frac_part}"
    end
  end

  defp add_commas(int) do
    int
    |> Integer.to_string()
    |> String.reverse()
    |> String.replace(~r/.{3}/, "\\0,")
    |> String.trim_trailing(",")
    |> String.reverse()
  end

  defp trim_trailing_zeros(str) do
    if String.contains?(str, ".") do
      str |> String.trim_trailing("0") |> String.trim_trailing(".")
    else
      str
    end
  end

  @doc """
  Keying options for `Phoenix.HTML.Form.options_for_select/2`, grouped for a
  cleaner UI when dozens of weak-signal modes are present.
  """
  @spec keying_options() :: [{String.t(), [{String.t(), String.t()}]}]
  def keying_options do
    [
      {"Simple",
       [
         {"On/Off", "on_off"},
         {"FSK", "fsk"}
       ]},
      {"Voice / Digital",
       [
         {"FM Voice", "fm_voice"},
         {"WSPR", "wspr"}
       ]},
      {"Q65A", q65_group("a")},
      {"Q65B", q65_group("b")},
      {"Q65C", q65_group("c")},
      {"Q65D", q65_group("d")},
      {"Q65E", q65_group("e")}
    ]
  end

  defp q65_group(letter) do
    for period <- ~w(15 30 60 120) do
      {"Q65#{String.upcase(letter)}-#{period}", "q65#{letter}_#{period}"}
    end
  end

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "beacons" do
    field :frequency_mhz, :float
    field :callsign, :string
    field :grid, :string
    field :lat, :float
    field :lon, :float
    field :power_mw, :float
    field :height_ft, :integer
    field :on_the_air, :boolean, default: true
    field :approved, :boolean, default: false
    field :keying, :string, default: "on_off"
    field :bearing, :string, default: "omni"
    field :beamwidth_deg, :float
    field :notes, :string

    belongs_to :user, Microwaveprop.Accounts.User

    timestamps(type: :utc_datetime)
  end

  @type t :: %__MODULE__{}

  @required_fields [:frequency_mhz, :callsign, :lat, :lon, :power_mw, :height_ft, :keying]

  @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
  def changeset(beacon, attrs) do
    beacon
    |> cast(attrs, [
      :frequency_mhz,
      :callsign,
      :grid,
      :lat,
      :lon,
      :power_mw,
      :height_ft,
      :on_the_air,
      :keying,
      :bearing,
      :beamwidth_deg,
      :notes
    ])
    |> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end)
    |> update_change(:lat, &round_coord/1)
    |> update_change(:lon, &round_coord/1)
    |> update_change(:height_ft, &round_int/1)
    |> maybe_fill_latlon()
    |> maybe_fill_grid()
    |> normalize_bearing_change()
    |> validate_required(@required_fields)
    |> validate_inclusion(:keying, @keyings)
    |> validate_bearing()
    |> validate_number(:beamwidth_deg, greater_than: 0, less_than_or_equal_to: 360)
    |> validate_number(:frequency_mhz, greater_than: 0)
    |> validate_number(:power_mw, greater_than_or_equal_to: 0)
    |> validate_number(:height_ft, greater_than_or_equal_to: 0)
    |> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
    |> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
    |> validate_length(:callsign, min: 3, max: 10)
    |> validate_grid_format()
  end

  defp round_coord(nil), do: nil
  defp round_coord(v) when is_float(v), do: Float.round(v, 6)
  defp round_coord(v), do: v

  defp round_int(nil), do: nil
  defp round_int(v) when is_float(v), do: round(v)
  defp round_int(v) when is_integer(v), do: v
  defp round_int(v), do: v

  # If grid is blank but lat/lon are valid, derive it.
  defp maybe_fill_grid(changeset) do
    grid = get_field(changeset, :grid)
    lat = get_field(changeset, :lat)
    lon = get_field(changeset, :lon)

    if grid in [nil, ""] and is_number(lat) and is_number(lon) do
      put_change(changeset, :grid, Maidenhead.from_latlon(lat, lon, 6))
    else
      changeset
    end
  end

  # If lat/lon are blank but grid is a valid Maidenhead, derive them.
  defp maybe_fill_latlon(changeset) do
    grid = get_field(changeset, :grid)
    lat = get_field(changeset, :lat)
    lon = get_field(changeset, :lon)

    if is_binary(grid) and grid != "" and (lat in [nil, ""] or lon in [nil, ""]) do
      case Maidenhead.to_latlon(grid) do
        {:ok, {new_lat, new_lon}} ->
          changeset
          |> put_change(:lat, Float.round(new_lat * 1.0, 6))
          |> put_change(:lon, Float.round(new_lon * 1.0, 6))

        :error ->
          changeset
      end
    else
      changeset
    end
  end

  # Normalize bearing: trim, treat nil/blank/"omni" (any case) as "omni".
  # Anything else is left for validate_bearing to check as a number.
  defp normalize_bearing_change(changeset) do
    current = get_field(changeset, :bearing)
    normalized = normalize_bearing(current)

    if normalized == current do
      changeset
    else
      put_change(changeset, :bearing, normalized)
    end
  end

  defp normalize_bearing(nil), do: "omni"

  defp normalize_bearing(value) when is_binary(value) do
    trimmed = String.trim(value)

    cond do
      trimmed == "" -> "omni"
      String.downcase(trimmed) == "omni" -> "omni"
      true -> trimmed
    end
  end

  defp normalize_bearing(value), do: value

  defp validate_bearing(changeset) do
    case get_field(changeset, :bearing) do
      "omni" ->
        changeset

      value when is_binary(value) ->
        case Float.parse(value) do
          {n, ""} when n >= 0 and n <= 360 ->
            changeset

          _ ->
            add_error(changeset, :bearing, ~s(must be "omni" or a number between 0 and 360))
        end

      _ ->
        changeset
    end
  end

  defp validate_grid_format(changeset) do
    case get_field(changeset, :grid) do
      nil ->
        add_error(changeset, :grid, "can't be blank")

      grid ->
        if Maidenhead.valid?(grid) do
          update_change(changeset, :grid, &normalize_grid/1)
        else
          add_error(changeset, :grid, "is not a valid Maidenhead grid")
        end
    end
  end

  # Field uppercase, square digits, subsquare lowercase (standard form)
  defp normalize_grid(nil), do: nil

  defp normalize_grid(grid) do
    grid = String.trim(grid)
    len = String.length(grid)

    if len >= 6 do
      grid |> String.slice(0, 4) |> String.upcase() |> Kernel.<>(String.downcase(String.slice(grid, 4, len - 4)))
    else
      String.upcase(grid)
    end
  end
end
