src/geokit@geohash.erl

-module(geokit@geohash).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/geokit/geohash.gleam").
-export([encode/2, decode_bounds/1, decode/1, neighbor/2, neighbors/1]).
-export_type([geohash_error/0, direction/0, neighbors/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " Niemeyer geohash encoding and decoding.\n"
    "\n"
    " A geohash is a short alphanumeric string identifying a\n"
    " rectangular cell on the Earth's surface. Longer hashes pinpoint\n"
    " smaller cells. The alphabet is base32 (10 digits + 22 letters,\n"
    " omitting `a`, `i`, `l`, `o` to avoid visual ambiguity); each\n"
    " character contributes 5 bits, alternating between longitude and\n"
    " latitude.\n"
    "\n"
    " See <http://geohash.org/> for the original specification.\n"
).

-type geohash_error() :: {precision_out_of_range, integer()} |
    empty_hash |
    {invalid_character, binary(), integer()}.

-type direction() :: north |
    south |
    east |
    west |
    north_east |
    north_west |
    south_east |
    south_west.

-type neighbors() :: {neighbors,
        binary(),
        binary(),
        binary(),
        binary(),
        binary(),
        binary(),
        binary(),
        binary()}.

-file("src/geokit/geohash.gleam", 99).
-spec encode_bits(
    float(),
    float(),
    float(),
    float(),
    float(),
    float(),
    integer(),
    boolean(),
    list(integer())
) -> list(integer()).
encode_bits(
    Lat_min,
    Lat_max,
    Lng_min,
    Lng_max,
    Lat,
    Lng,
    Remaining,
    Use_lng,
    Acc
) ->
    gleam@bool:guard(Remaining =< 0, Acc, fun() -> case Use_lng of
                true ->
                    Mid = (Lng_min + Lng_max) / 2.0,
                    Go_high = Lng > Mid,
                    New_min = case Go_high of
                        true ->
                            Mid;

                        false ->
                            Lng_min
                    end,
                    New_max = case Go_high of
                        true ->
                            Lng_max;

                        false ->
                            Mid
                    end,
                    Bit = case Go_high of
                        true ->
                            1;

                        false ->
                            0
                    end,
                    encode_bits(
                        Lat_min,
                        Lat_max,
                        New_min,
                        New_max,
                        Lat,
                        Lng,
                        Remaining - 1,
                        false,
                        [Bit | Acc]
                    );

                false ->
                    Mid@1 = (Lat_min + Lat_max) / 2.0,
                    Go_high@1 = Lat > Mid@1,
                    New_min@1 = case Go_high@1 of
                        true ->
                            Mid@1;

                        false ->
                            Lat_min
                    end,
                    New_max@1 = case Go_high@1 of
                        true ->
                            Lat_max;

                        false ->
                            Mid@1
                    end,
                    Bit@1 = case Go_high@1 of
                        true ->
                            1;

                        false ->
                            0
                    end,
                    encode_bits(
                        New_min@1,
                        New_max@1,
                        Lng_min,
                        Lng_max,
                        Lat,
                        Lng,
                        Remaining - 1,
                        true,
                        [Bit@1 | Acc]
                    )
            end end).

-file("src/geokit/geohash.gleam", 253).
-spec decode_bits(
    list(integer()),
    float(),
    float(),
    float(),
    float(),
    boolean()
) -> {float(), float(), float(), float()}.
decode_bits(Bits, Lat_min, Lat_max, Lng_min, Lng_max, Use_lng) ->
    case Bits of
        [] ->
            {Lat_min, Lat_max, Lng_min, Lng_max};

        [Bit | Rest] ->
            case Use_lng of
                true ->
                    Mid = (Lng_min + Lng_max) / 2.0,
                    case Bit of
                        1 ->
                            decode_bits(
                                Rest,
                                Lat_min,
                                Lat_max,
                                Mid,
                                Lng_max,
                                false
                            );

                        _ ->
                            decode_bits(
                                Rest,
                                Lat_min,
                                Lat_max,
                                Lng_min,
                                Mid,
                                false
                            )
                    end;

                false ->
                    Mid@1 = (Lat_min + Lat_max) / 2.0,
                    case Bit of
                        1 ->
                            decode_bits(
                                Rest,
                                Mid@1,
                                Lat_max,
                                Lng_min,
                                Lng_max,
                                true
                            );

                        _ ->
                            decode_bits(
                                Rest,
                                Lat_min,
                                Mid@1,
                                Lng_min,
                                Lng_max,
                                true
                            )
                    end
            end
    end.

-file("src/geokit/geohash.gleam", 319).
-spec index_in_alphabet_loop(binary(), binary(), integer()) -> {ok, integer()} |
    {error, nil}.
index_in_alphabet_loop(Needle, Haystack, Position) ->
    case gleam_stdlib:string_pop_grapheme(Haystack) of
        {error, nil} ->
            {error, nil};

        {ok, {Head, Tail}} ->
            gleam@bool:guard(
                Head =:= Needle,
                {ok, Position},
                fun() -> index_in_alphabet_loop(Needle, Tail, Position + 1) end
            )
    end.

-file("src/geokit/geohash.gleam", 474).
-spec index_of_char_in(binary(), binary(), integer()) -> {ok, integer()} |
    {error, nil}.
index_of_char_in(Haystack, Needle, Position) ->
    case gleam_stdlib:string_pop_grapheme(Haystack) of
        {error, nil} ->
            {error, nil};

        {ok, {Head, Tail}} ->
            gleam@bool:guard(
                Head =:= Needle,
                {ok, Position},
                fun() -> index_of_char_in(Tail, Needle, Position + 1) end
            )
    end.

-file("src/geokit/geohash.gleam", 169).
-spec bits_to_base32(list(integer()), binary()) -> binary().
bits_to_base32(Bits, Acc) ->
    case Bits of
        [B0, B1, B2, B3, B4 | Rest] ->
            Value = ((((B0 * 16) + (B1 * 8)) + (B2 * 4)) + (B3 * 2)) + B4,
            Char = case gleam@string:slice(
                <<"0123456789bcdefghjkmnpqrstuvwxyz"/utf8>>,
                Value,
                1
            ) of
                <<""/utf8>> ->
                    <<"0"/utf8>>;

                Ch ->
                    Ch
            end,
            bits_to_base32(Rest, <<Acc/binary, Char/binary>>);

        _ ->
            Acc
    end.

-file("src/geokit/geohash.gleam", 75).
?DOC(
    " Encode a [`LatLng`](../latlng.html#LatLng) as a base32 geohash of\n"
    " the requested precision. Precision is the number of output\n"
    " characters and must be in `[1, 12]`. Each additional character\n"
    " shrinks the cell width by a factor of ~5.6.\n"
    "\n"
    " ```gleam\n"
    " import geokit/geohash\n"
    " import geokit/latlng\n"
    "\n"
    " let assert Ok(tokyo) = latlng.new(lat: 35.6812, lng: 139.7671)\n"
    " let assert Ok(hash) = geohash.encode(point: tokyo, precision: 8)\n"
    " // hash == \"xn76urx4\"\n"
    " ```\n"
).
-spec encode(geokit@latlng:lat_lng(), integer()) -> {ok, binary()} |
    {error, geohash_error()}.
encode(Point, Precision) ->
    gleam@bool:guard(
        (Precision < 1) orelse (Precision > 12),
        {error, {precision_out_of_range, Precision}},
        fun() ->
            Target_bits = Precision * 5,
            Bits = encode_bits(
                -90.0,
                90.0,
                -180.0,
                180.0,
                geokit@latlng:lat(Point),
                geokit@latlng:lng(Point),
                Target_bits,
                true,
                []
            ),
            {ok, bits_to_base32(lists:reverse(Bits), <<""/utf8>>)}
        end
    ).

-file("src/geokit/geohash.gleam", 315).
-spec index_in_alphabet(binary()) -> {ok, integer()} | {error, nil}.
index_in_alphabet(Char) ->
    index_in_alphabet_loop(Char, <<"0123456789bcdefghjkmnpqrstuvwxyz"/utf8>>, 0).

-file("src/geokit/geohash.gleam", 230).
-spec hash_to_bits(binary(), integer(), list(integer())) -> {ok,
        list(integer())} |
    {error, geohash_error()}.
hash_to_bits(Hash, Position, Acc) ->
    case gleam_stdlib:string_pop_grapheme(Hash) of
        {error, nil} ->
            {ok, Acc};

        {ok, {Head, Tail}} ->
            case index_in_alphabet(Head) of
                {error, nil} ->
                    {error, {invalid_character, Head, Position}};

                {ok, Value} ->
                    hash_to_bits(
                        Tail,
                        Position + 1,
                        [Value rem 2,
                            (Value div 2) rem 2,
                            (Value div 4) rem 2,
                            (Value div 8) rem 2,
                            (Value div 16) rem 2 |
                            Acc]
                    )
            end
    end.

-file("src/geokit/geohash.gleam", 209).
?DOC(
    " Decode a geohash to the south-west and north-east corners of its\n"
    " cell, returned as a tuple `#(sw, ne)`.\n"
    "\n"
    " The input is case-insensitive: upper-case characters are folded to\n"
    " lower-case before lookup. This matches `chrisveness/latlon-geohash`\n"
    " and `ngeohash`.\n"
).
-spec decode_bounds(binary()) -> {ok,
        {geokit@latlng:lat_lng(), geokit@latlng:lat_lng()}} |
    {error, geohash_error()}.
decode_bounds(Hash) ->
    Normalised = string:lowercase(Hash),
    gleam@bool:guard(
        Normalised =:= <<""/utf8>>,
        {error, empty_hash},
        fun() ->
            gleam@result:'try'(
                hash_to_bits(Normalised, 0, []),
                fun(Bits) ->
                    {Lat_min, Lat_max, Lng_min, Lng_max} = decode_bits(
                        lists:reverse(Bits),
                        -90.0,
                        90.0,
                        -180.0,
                        180.0,
                        true
                    ),
                    {ok,
                        {geokit@latlng:wrap(Lat_min, Lng_min),
                            geokit@latlng:wrap(Lat_max, Lng_max)}}
                end
            )
        end
    ).

-file("src/geokit/geohash.gleam", 196).
?DOC(
    " Decode a geohash to the centre of the cell it identifies.\n"
    "\n"
    " The input is case-insensitive: upper-case characters are folded\n"
    " to lower-case before lookup.\n"
    "\n"
    " ```gleam\n"
    " import geokit/geohash\n"
    "\n"
    " let assert Ok(centre) = geohash.decode(\"xn76urx4\")\n"
    " // centre ≈ (35.6812, 139.7671)\n"
    " ```\n"
).
-spec decode(binary()) -> {ok, geokit@latlng:lat_lng()} |
    {error, geohash_error()}.
decode(Hash) ->
    gleam@result:'try'(
        decode_bounds(Hash),
        fun(_use0) ->
            {Sw, Ne} = _use0,
            Lat = (geokit@latlng:lat(Sw) + geokit@latlng:lat(Ne)) / 2.0,
            Lng = (geokit@latlng:lng(Sw) + geokit@latlng:lng(Ne)) / 2.0,
            {ok, geokit@latlng:wrap(Lat, Lng)}
        end
    ).

-file("src/geokit/geohash.gleam", 488).
-spec neighbor_table(direction(), boolean()) -> binary().
neighbor_table(Direction, Is_even) ->
    case {Direction, Is_even} of
        {north, true} ->
            <<"p0r21436x8zb9dcf5h7kjnmqesgutwvy"/utf8>>;

        {north, false} ->
            <<"bc01fg45238967deuvhjyznpkmstqrwx"/utf8>>;

        {east, true} ->
            <<"bc01fg45238967deuvhjyznpkmstqrwx"/utf8>>;

        {east, false} ->
            <<"p0r21436x8zb9dcf5h7kjnmqesgutwvy"/utf8>>;

        {south, true} ->
            <<"14365h7k9dcfesgujnmqp0r2twvyx8zb"/utf8>>;

        {south, false} ->
            <<"238967debc01fg45kmstqrwxuvhjyznp"/utf8>>;

        {west, true} ->
            <<"238967debc01fg45kmstqrwxuvhjyznp"/utf8>>;

        {west, false} ->
            <<"14365h7k9dcfesgujnmqp0r2twvyx8zb"/utf8>>;

        {_, _} ->
            <<""/utf8>>
    end.

-file("src/geokit/geohash.gleam", 505).
-spec border_table(direction(), boolean()) -> binary().
border_table(Direction, Is_even) ->
    case {Direction, Is_even} of
        {north, true} ->
            <<"prxz"/utf8>>;

        {north, false} ->
            <<"bcfguvyz"/utf8>>;

        {east, true} ->
            <<"bcfguvyz"/utf8>>;

        {east, false} ->
            <<"prxz"/utf8>>;

        {south, true} ->
            <<"028b"/utf8>>;

        {south, false} ->
            <<"0145hjnp"/utf8>>;

        {west, true} ->
            <<"0145hjnp"/utf8>>;

        {west, false} ->
            <<"028b"/utf8>>;

        {_, _} ->
            <<""/utf8>>
    end.

-file("src/geokit/geohash.gleam", 438).
-spec step(binary(), direction()) -> {ok, binary()} | {error, geohash_error()}.
step(Hash, Direction) ->
    gleam@bool:guard(
        Hash =:= <<""/utf8>>,
        {error, empty_hash},
        fun() ->
            Length = string:length(Hash),
            Parent_length = Length - 1,
            Parent = gleam@string:slice(Hash, 0, Parent_length),
            Last = gleam@string:slice(Hash, Parent_length, 1),
            Is_even = (Length rem 2) =:= 0,
            Border = border_table(Direction, Is_even),
            Lookup = neighbor_table(Direction, Is_even),
            Parent_result = case gleam_stdlib:contains_string(Border, Last) of
                true ->
                    case Parent of
                        <<""/utf8>> ->
                            {error, empty_hash};

                        _ ->
                            step(Parent, Direction)
                    end;

                false ->
                    {ok, Parent}
            end,
            gleam@result:'try'(
                Parent_result,
                fun(New_parent) -> case index_of_char_in(Lookup, Last, 0) of
                        {error, nil} ->
                            {error, {invalid_character, Last, Parent_length}};

                        {ok, Value} ->
                            {ok,
                                <<New_parent/binary,
                                    (gleam@string:slice(
                                        <<"0123456789bcdefghjkmnpqrstuvwxyz"/utf8>>,
                                        Value,
                                        1
                                    ))/binary>>}
                    end end
            )
        end
    ).

-file("src/geokit/geohash.gleam", 406).
-spec two_steps(binary(), direction(), direction()) -> {ok, binary()} |
    {error, geohash_error()}.
two_steps(Hash, First, Second) ->
    gleam@result:'try'(
        neighbor(Hash, First),
        fun(Partial) -> neighbor(Partial, Second) end
    ).

-file("src/geokit/geohash.gleam", 389).
?DOC(
    " The neighbour of `hash` in the given [`Direction`](#Direction).\n"
    "\n"
    " Returns [`EmptyHash`](#GeohashError) when called on the empty\n"
    " string. Wraps across the antimeridian for east / west, returns\n"
    " [`EmptyHash`](#GeohashError) when a polar neighbour would cross\n"
    " the pole (north of the northernmost row or south of the\n"
    " southernmost row).\n"
    "\n"
    " The input is case-insensitive: upper-case characters are folded\n"
    " to lower-case before lookup, matching `chrisveness/latlon-geohash`\n"
    " and `ngeohash`.\n"
).
-spec neighbor(binary(), direction()) -> {ok, binary()} |
    {error, geohash_error()}.
neighbor(Hash, Direction) ->
    Normalised = string:lowercase(Hash),
    case Direction of
        north ->
            step(Normalised, north);

        south ->
            step(Normalised, south);

        east ->
            step(Normalised, east);

        west ->
            step(Normalised, west);

        north_east ->
            two_steps(Normalised, north, east);

        north_west ->
            two_steps(Normalised, north, west);

        south_east ->
            two_steps(Normalised, south, east);

        south_west ->
            two_steps(Normalised, south, west)
    end.

-file("src/geokit/geohash.gleam", 417).
?DOC(
    " All eight neighbours of `hash`, returned as a\n"
    " [`Neighbors`](#Neighbors) record.\n"
).
-spec neighbors(binary()) -> {ok, neighbors()} | {error, geohash_error()}.
neighbors(Hash) ->
    gleam@result:'try'(
        neighbor(Hash, north),
        fun(N) ->
            gleam@result:'try'(
                neighbor(Hash, south),
                fun(S) ->
                    gleam@result:'try'(
                        neighbor(Hash, east),
                        fun(E) ->
                            gleam@result:'try'(
                                neighbor(Hash, west),
                                fun(W) ->
                                    gleam@result:'try'(
                                        neighbor(Hash, north_east),
                                        fun(Ne) ->
                                            gleam@result:'try'(
                                                neighbor(Hash, north_west),
                                                fun(Nw) ->
                                                    gleam@result:'try'(
                                                        neighbor(
                                                            Hash,
                                                            south_east
                                                        ),
                                                        fun(Se) ->
                                                            gleam@result:'try'(
                                                                neighbor(
                                                                    Hash,
                                                                    south_west
                                                                ),
                                                                fun(Sw) ->
                                                                    {ok,
                                                                        {neighbors,
                                                                            N,
                                                                            S,
                                                                            E,
                                                                            W,
                                                                            Ne,
                                                                            Nw,
                                                                            Se,
                                                                            Sw}}
                                                                end
                                                            )
                                                        end
                                                    )
                                                end
                                            )
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).