defmodule Aprsme.Encoding do
  @moduledoc """
  Encoding utilities for handling APRS packet data.
  Provides functions for sanitizing strings, converting encodings,
  and validating data.
  """

  @doc """
  Sanitizes a binary to ensure it can be safely JSON encoded.
  Handles latin1 conversion and removes control characters.
  """
  @spec sanitize_string(binary()) :: binary()
  def sanitize_string(input) when is_binary(input) do
    input
    |> try_utf8_conversion()
    |> clean_control_characters()
  end

  @spec sanitize_string(any()) :: binary()
  def sanitize_string(_), do: ""

  @doc """
  Removes Mic-E telemetry data from APRS comments.
  Mic-E telemetry appears as sequences like !w>`! at the start of comments.
  The pattern is: ! followed by 3 characters followed by !
  """
  @spec strip_mice_telemetry(binary()) :: binary()
  def strip_mice_telemetry(comment) when is_binary(comment) do
    # Pattern: one or more sequences of !...! (5 chars each) at the start
    # Examples: !w>`!, !w_'!, !w_P!, !w;i!, !w;D!
    String.replace(comment, ~r/^(?:![^!]{3}!)+/, "")
  end

  def strip_mice_telemetry(nil), do: nil
  def strip_mice_telemetry(other), do: other

  @doc """
  Type-safe float conversion with validation
  """
  @spec to_float_safe(binary()) :: {:ok, float()} | nil
  def to_float_safe(value) when is_binary(value) do
    sanitized =
      value
      |> String.trim()
      # Reasonable max length for a number
      |> String.slice(0, 30)

    case Float.parse(sanitized) do
      {f, _} when f > -9.0e15 and f < 9.0e15 ->
        {:ok, f}

      _ ->
        nil
    end
  end

  @spec to_float_safe(any()) :: nil
  def to_float_safe(_), do: nil

  @doc """
  Convert binary to hex string
  """
  @spec to_hex(binary()) :: binary()
  def to_hex(input) when is_binary(input) do
    Base.encode16(input)
  end

  @spec to_hex(any()) :: binary()
  def to_hex(_), do: ""

  @doc """
  Check if a value looks like it has weather data
  """
  @spec has_weather_data(any(), any(), any(), any()) :: boolean()
  def has_weather_data(temperature, humidity, wind_speed, pressure) do
    not is_nil(temperature) or
      not is_nil(humidity) or
      not is_nil(wind_speed) or
      not is_nil(pressure)
  end

  @doc """
  Get encoding information about a binary
  """
  @spec encoding_info(binary()) :: %{
          valid_utf8: boolean(),
          byte_count: non_neg_integer(),
          char_count: non_neg_integer() | nil,
          invalid_at: non_neg_integer() | nil
        }
  def encoding_info(input) when is_binary(input) do
    build_encoding_info(String.valid?(input), input)
  end

  @spec encoding_info(any()) :: %{
          valid_utf8: boolean(),
          byte_count: non_neg_integer(),
          char_count: nil,
          invalid_at: nil
        }
  def encoding_info(_) do
    %{
      valid_utf8: false,
      byte_count: 0,
      char_count: nil,
      invalid_at: nil
    }
  end

  defp build_encoding_info(true, input) do
    %{
      valid_utf8: true,
      byte_count: byte_size(input),
      char_count: String.length(input),
      invalid_at: nil
    }
  end

  defp build_encoding_info(false, input) do
    %{
      valid_utf8: false,
      byte_count: byte_size(input),
      char_count: nil,
      invalid_at: find_invalid_byte_position(input)
    }
  end

  # Private functions

  @spec try_utf8_conversion(binary()) :: binary()
  defp try_utf8_conversion(input) do
    convert_to_utf8(String.valid?(input), input)
  end

  # Valid UTF-8 already — pass through. Otherwise try latin1 → UTF-8.
  defp convert_to_utf8(true, input), do: input
  defp convert_to_utf8(false, input), do: latin1_to_utf8(input)

  @spec latin1_to_utf8(binary()) :: binary()
  defp latin1_to_utf8(input) do
    input
    |> :binary.bin_to_list()
    |> Enum.map(&latin1_char_to_utf8/1)
    |> IO.iodata_to_binary()
  rescue
    _ -> ""
  end

  @spec latin1_char_to_utf8(0..255) :: binary()
  defp latin1_char_to_utf8(byte) when byte <= 127 do
    <<byte>>
  end

  defp latin1_char_to_utf8(byte) when byte >= 128 and byte <= 159 do
    # C1 control range (128-159) has no useful Latin1 characters.
    # Replace with Unicode replacement character instead of converting
    # to C1 control codepoints that would just get stripped later.
    <<0xEF, 0xBF, 0xBD>>
  end

  defp latin1_char_to_utf8(byte) do
    # For Latin1, values 160-255 map to Unicode U+00A0 to U+00FF
    # In UTF-8, these become 2-byte sequences: 110xxxxx 10xxxxxx
    <<0xC0 + div(byte, 64), 0x80 + rem(byte, 64)>>
  end

  @spec clean_control_characters(binary()) :: binary()
  defp clean_control_characters(s) do
    if all_bytes_clean?(s) do
      String.trim(s)
    else
      s
      |> String.graphemes()
      |> Enum.filter(&valid_grapheme?/1)
      |> Enum.join()
      |> String.trim()
    end
  end

  # Fast-path check: returns true when every byte is safe ASCII
  # (printable chars 32-126, plus tab/newline/CR). Any byte >= 128
  # or any control character triggers the slow grapheme-based path.
  @spec all_bytes_clean?(binary()) :: boolean()
  defp all_bytes_clean?(<<>>), do: true
  defp all_bytes_clean?(<<9, rest::binary>>), do: all_bytes_clean?(rest)
  defp all_bytes_clean?(<<10, rest::binary>>), do: all_bytes_clean?(rest)
  defp all_bytes_clean?(<<13, rest::binary>>), do: all_bytes_clean?(rest)
  defp all_bytes_clean?(<<byte, rest::binary>>) when byte >= 32 and byte <= 126, do: all_bytes_clean?(rest)
  defp all_bytes_clean?(_), do: false

  @spec valid_grapheme?(String.grapheme()) :: boolean()
  defp valid_grapheme?(grapheme) do
    case String.to_charlist(grapheme) do
      [cp] -> valid_codepoint?(cp)
      _ -> true
    end
  end

  # Allow tab (0x09), newline (0x0A), carriage return (0x0D)
  # Remove other control characters
  defp valid_codepoint?(9), do: true
  defp valid_codepoint?(10), do: true
  defp valid_codepoint?(13), do: true
  defp valid_codepoint?(127), do: false
  defp valid_codepoint?(c) when c >= 0 and c <= 31, do: false
  defp valid_codepoint?(c) when c >= 128 and c <= 159, do: false
  defp valid_codepoint?(_), do: true

  @spec find_invalid_byte_position(binary()) :: non_neg_integer() | nil
  defp find_invalid_byte_position(input) do
    input
    |> :binary.bin_to_list()
    |> Enum.with_index()
    |> Enum.find_value(fn {byte, index} ->
      if byte > 127 and not valid_utf8_continuation?(input, index) do
        index
      end
    end)
  end

  @spec valid_utf8_continuation?(binary(), non_neg_integer()) :: boolean()
  defp valid_utf8_continuation?(binary, pos) do
    <<_::binary-size(^pos), _char::utf8, _::binary>> = binary
    true
  rescue
    _ -> false
  end
end
