src/support/z_props.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2020-2026 Marc Worrell
%% @doc Query string processing, property lists and property maps for
%% Zotonic resources.
%% @end

%% Copyright 2020-2026 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

-module(z_props).

-include_lib("../../include/zotonic.hrl").

-export([
    from_map/1,
    from_props/1,

    from_list/1,
    from_qs/1,
    from_qs/2,

    extract_languages/1,
    prune_languages/2,

    normalize_dates/3,
    common_properties/0,
    property_name_type_hint/1
    ]).

%% Query String key, value and property types.
%% Used as input for the from_qs/1,2 functions.
-type qs_key() :: binary().
-type qs_value() :: binary() | #upload{} | term().
-type qs_prop() :: { qs_key(), qs_value() }.
-type type_hint_t() :: id
                     | binary
                     | text
                     | html
                     | int
                     | datetime
                     | list
                     | language
                     | float
                     | bool
                     | uri
                     | email
                     | unsafe.

-export_type([
    qs_key/0,
    qs_value/0,
    qs_prop/0,
    type_hint_t/0
    ]).

%% We use -4700 as the most prehistoric date, as postgresql can't handle
%% dates before this date.
-define(EPOCH_START_YEAR, -4700).
-define(EPOCH_END_YEAR, 9999).


%% @doc Transform a map to a nested map with binary keys.
-spec from_map( map() | undefined ) -> map().
from_map(undefined) ->
    #{};
from_map(Map) ->
    maps:fold(
        fun(K, V, Acc) ->
            K1 = to_binary(K),
            Acc#{ K1 => from_any(V) }
        end,
        #{},
        Map).

from_any(M) when is_map(M) -> from_map(M);
from_any([ {_, _} | _ ] = L) -> from_props(L);
from_any(V) -> V.

%% @doc Transform a proplist from older resources and/or code to a (nested) map.
%%      This knows how to handle nestes lists like the 'blocks' list of lists.
-spec from_props( proplists:proplist() | undefined ) -> map().
from_props(undefined) ->
    #{};
from_props(Ps) when is_list(Ps) ->
    from_props_1(Ps).

from_props_1(Ps) when is_list(Ps) ->
    L1 = lists:map(fun from_prop/1, Ps),
    maps:from_list(L1);
from_props_1(P) ->
    P.

from_prop({K, [ [{_,_}|_] | _ ] = Vs}) ->
    % This is typical for the 'blocks' property, which contains
    % a list of blocks. Where each block is a property list.
    {to_binary(K), lists:map(fun from_props_1/1, Vs)};
from_prop({K, V}) ->
    from_prop_value(to_binary(K), V);
from_prop(K) when is_atom(K) ->
    {to_binary(K), true};
from_prop(K) ->
    K.

to_binary(K) when is_atom(K) -> atom_to_binary(K, utf8);
to_binary(K) when is_binary(K) -> K;
to_binary(K) -> K.

%% @doc Common resource properties used by exporter and backup routines.
-spec common_properties() -> [binary()].
common_properties() ->
    [
        <<"title">>,

        <<"category_id">>,
        <<"creator_id">>,
        <<"modifier_id">>,

        <<"created">>,
        <<"modified">>,

        <<"publication_start">>,
        <<"publication_end">>,

        <<"is_published">>,
        <<"is_featured">>,
        <<"is_protected">>,

        <<"chapeau">>,
        <<"subtitle">>,
        <<"short_title">>,
        <<"summary">>,

        <<"name_prefix">>,
        <<"name_first">>,
        <<"name_surname_prefix">>,
        <<"name_surname">>,

        <<"phone">>,
        <<"phone_mobile">>,
        <<"phone_alt">>,
        <<"phone_emergency">>,

        <<"email">>,
        <<"website">>,

        <<"date_start">>,
        <<"date_end">>,
        <<"date_remarks">>,

        <<"address_street_1">>,
        <<"address_street_2">>,
        <<"address_city">>,
        <<"address_state">>,
        <<"address_postcode">>,
        <<"address_country">>,

        <<"mail_email">>,
        <<"mail_street_1">>,
        <<"mail_street_2">>,
        <<"mail_city">>,
        <<"mail_state">>,
        <<"mail_postcode">>,
        <<"mail_country">>,

        <<"billing_email">>,
        <<"billing_street_1">>,
        <<"billing_street_2">>,
        <<"billing_city">>,
        <<"billing_state">>,
        <<"billing_postcode">>,
        <<"billing_country">>,

        <<"location_lng">>,
        <<"location_lat">>,

        <<"body">>,
        <<"body_extra">>,
        <<"blocks">>,

        <<"page_path">>,
        <<"name">>,

        <<"seo_noindex">>,
        <<"title_slug">>,
        <<"custom_slug">>,
        <<"seo_desc">>
    ].

%% @doc Return a type-hint atom derived from a property name.
-spec property_name_type_hint(Key) -> type_hint_t() | undefined when
        Key :: binary().
property_name_type_hint(Key)
    when Key =:= <<"id">>;
         Key =:= <<"creator_id">>;
         Key =:= <<"modifier_id">>;
         Key =:= <<"rsc_id">>;
         Key =:= <<"rsc_id2">> ->
    id;
property_name_type_hint(Key)
    when Key =:= <<"name">>;
         Key =:= <<"type">>;
         Key =:= <<"tz">> ->
    binary;
property_name_type_hint(Key)
    when Key =:= <<"version">>;
         Key =:= <<"visible_for">>;
         Key =:= <<"privacy">> ->
    int;
property_name_type_hint(Key)
    when Key =:= <<"created">>;
         Key =:= <<"modified">>;
         Key =:= <<"date_start">>;
         Key =:= <<"date_end">>;
         Key =:= <<"org_pubdate">>;
         Key =:= <<"publication_start">>;
         Key =:= <<"publication_end">> ->
    datetime;
property_name_type_hint(Key)
    when Key =:= <<"language">>;
         Key =:= <<"blocks">>;
         Key =:= <<"is_a">> ->
    list;
property_name_type_hint(Key)
    when Key =:= <<"pref_language">>;
         Key =:= <<"medium_language">> ->
    language;
property_name_type_hint(Key)
    when Key =:= <<"location_lat">>;
         Key =:= <<"location_lng">> ->
    float;
property_name_type_hint(<<"is_", _/binary>>) ->
    bool;
property_name_type_hint(<<"date_is_", _/binary>>) ->
    bool;
property_name_type_hint(Key)
    when Key =:= <<"seo_noindex">> ->
    bool;
property_name_type_hint(Key)
    when Key =:= <<"@id">>;
         Key =:= <<"website">> ->
    uri;
property_name_type_hint(Key)
    when Key =:= <<"email">> ->
    email;
property_name_type_hint(Key)
    when Key =:= <<"body">>;
         Key =:= <<"body_extra">> ->
    html;
property_name_type_hint(Key)
    when Key =:= <<"date_remarks">>;
         Key =:= <<"chapeau">>;
         Key =:= <<"title">>;
         Key =:= <<"short_title">>;
         Key =:= <<"subtitle">>;
         Key =:= <<"summary">> ->
    text;
property_name_type_hint(Key) ->
    case extract_type(Key, <<>>) of
        <<"int">> -> int;
        <<"url">> -> uri;
        <<"uri">> -> uri;
        <<"email">> -> email;
        <<"html">> -> html;
        <<"list">> -> list;
        <<"id">> -> id;
        <<"date">> -> datetime;
        <<"unsafe">> -> unsafe;
        _ -> property_name_type_hint_1(Key)
    end.

property_name_type_hint_1(<<"email_", _/binary>>) ->
    email;
property_name_type_hint_1(<<"date_", _/binary>>) ->
    datetime;
property_name_type_hint_1(<<"address_", _/binary>>) ->
    binary;
property_name_type_hint_1(<<"mail_", _/binary>>) ->
    binary;
property_name_type_hint_1(<<"billing_", _/binary>>) ->
    binary;
property_name_type_hint_1(_) ->
    undefined.

extract_type(<<>>, Type) ->
    Type;
extract_type(<<"_", R/binary>>, _Type) ->
    extract_type(R, <<>>);
extract_type(<<C/utf8, R/binary>>, Type) ->
    extract_type(R, <<Type/binary, C/utf8>>).


from_prop_value(K, undefined) ->
    {K, undefined};
from_prop_value(K, V) when is_boolean(V) ->
    {K, V};
from_prop_value(<<"is_", _/binary>> = K, V) ->
    {K, z_convert:to_bool(V)};
from_prop_value(<<"date_is_", _/binary>> = K, V) ->
    {K, z_convert:to_bool(V)};
from_prop_value(K, V) when is_atom(V) ->
    {K, atom_to_binary(V, utf8)};
from_prop_value(K, "") ->
    {K, <<>>};
from_prop_value(K, [ C | _ ] = V) when is_integer(C), C >= 0, C =< 255 ->
    % Might be string with UTF8 encoded characters.
    % Can be found in legacy Zotonic 0.x code.
    try
        V1 = iolist_to_binary(V),
        {K, V1}
    catch
        error:badarg ->
            {K, V}
    end;
from_prop_value(K, #trans{ tr = Tr }) ->
    Tr1 = lists:filtermap(
        fun
            ({Iso, Text}) when is_atom(Iso), is_binary(Text) ->
                {true, {Iso, Text}};
            ({Iso, Text}) ->
                case z_language:to_language_atom(Iso) of
                    {ok, Code} ->
                        {true, {Code, z_convert:to_binary(Text)}};
                    _ ->
                        false
                end
        end,
        Tr),
    {K, #trans{ tr = Tr1 }};
from_prop_value(K, V) ->
    {K, V}.


%% @doc Convert a list for rsc insert and/or update to a map.
%%      The list could be a list of (binary) query args, or
%%      a property list with atom keys.
-spec from_list( list() ) -> {ok, #{ binary() => term() }}.
from_list([ {K, _} | _ ] = L) when is_atom(K) ->
    {ok, from_props(L)};
from_list([ {K, _} | _ ] = L) when is_binary(K) ->
    from_qs(L).


%% @doc Combine properties from a form. The form consists of a flat list
%% with query string properties.
%% The result is a map which can be used by the rsc and other routines.
%%
%% The keys can have a special format, specifying how they are processed:
%% <ul>
%%  <li> 'dt:ymd:0:property' is datetime property, 0 for start-date, 1 for end-date,
%%    the pattern 'ymd' describes what is in the value, could also be 'y', 'm',
%%    'd', 'dmy', 'his', 'hi', 'h', 'i', or 's'</li>
%%  <li> 'prop$en' is the 'en' translation of the property named 'prop'</li>
%%  <li> 'a.b.c.d' is a nested map</li>
%%  <li> 'a~1' is a multi occurence of 'a', useful for same named items</li>
%%  <li> 'blocks[].name' is a list of maps, use 'blocks[].' to append a new empty
%%    entry. Use 'name[]' to append to a list of values.</li>
%% </ul>
%%
%% If a date/time part is missing then the current UTC date is used to fill the
%% missing parts.
-spec from_qs( list( qs_prop() ) ) -> {ok, #{ binary() => term() }}.
from_qs(Qs) ->
    from_qs(Qs, calendar:universal_time()).

%% @doc Like from_qs/1, except that a specific date is used to fill in any
%       missing date/time parts.
-spec from_qs( list( qs_prop() ), calendar:datetime() ) ->
            {ok, #{ binary() => term() }}.
from_qs(Qs, Now) ->
    Nested = nested(Qs),
    WithDates = combine_dates(Nested, Now),
    WithTrans = combine_trans(WithDates),
    WithoutSingles = lift_singles(WithTrans),
    {ok, WithoutSingles}.


%% ---------------------------------------------------------------------------------------
%% Nested maps
%% ---------------------------------------------------------------------------------------

nested(Qs) ->
    Map = lists:foldl(
        fun({K, V}, Acc) ->
            [ K1 | _ ] = binary:split(K, <<"~">>),
            Parts = binary:split(K1, <<".">>, [global]),
            nested_assign(Parts, V, Acc)
        end,
        #{},
        Qs),
    reverse_lists(Map).

reverse_lists(Map) when is_map(Map) ->
    maps:map(
        fun
            (_K, M) when is_map(M) ->
                reverse_lists(M);
            (_K, L) when is_list(L) ->
                lists:foldl(
                    fun(V, Acc) ->
                        [ reverse_lists(V) | Acc ]
                    end,
                    [],
                    L);
            (_K, V) ->
                V
        end,
        Map);
reverse_lists(V) ->
    V.

nested_assign([ <<>> | _ ], _V, Map) ->
    % Drop empty keys
    Map;
nested_assign([ <<"block-">> ], V, Map) ->
    % Handle forms with old 'block-' editing templates
    nested_assign([ <<"blocks[].">> ], V, Map);
nested_assign([ <<"block-", Rest/binary>> ], V, Map) ->
    % Handle forms with old 'block-' editing templates
    case binary:split(Rest, <<"-">>) of
        [ _ , K ] -> nested_assign([ <<"blocks[].", K/binary>> ], V, Map);
        [ K ] -> nested_assign([ <<"blocks[].", K/binary>> ], V, Map)
    end;
nested_assign([ K, <<>> ], _V, Map) ->
    % This was a key 'a.b[].' which notifies the
    % start of a new map in a list 'b'
    case has_suffix(K, <<"[]">>) of
        true ->
            Len = size(K) - size(<<"[]">>),
            <<K1:Len/binary, "[]">> = K,
            case maps:get(K1, Map, []) of
                L when is_list(L) ->
                    Map#{ K1 => [ #{} | L ] };
                _ ->
                    Map#{ K1 => [ #{} ] }
            end;
        false ->
            Map
    end;
nested_assign([ K ], V, Map) ->
    case binary:split(K, <<"[">>) of
        [Key, KIdxPost] ->
            case binary:split(KIdxPost, <<"]">>) of
                [<<>>, <<>>] ->
                    % This was a key 'a.b[]' which appends a value to a list.
                    case maps:find(Key, Map) of
                        {ok, L} when is_list(L) ->
                            Map#{ Key => [ V | L ] };
                        {ok, _} ->
                            Map#{ Key => [ V ] };
                        error ->
                            Map#{ Key => [ V ] }
                    end;
                [Index, <<>>] ->
                    % This was a key a.b[123] which sets a value on a
                    % specific index value in a list; or a.b[]$en which
                    % appends a value to the list.
                    IndexNr = try
                                    max(1, binary_to_integer(Index))
                              catch _:_ ->
                                    append
                              end,
                    MaybeList = maps:get(Key, Map, []),
                    List = case is_list(MaybeList) of
                        true -> MaybeList;
                        false -> []
                    end,
                    List1 = lists:reverse(List),
                    M = get_index(IndexNr, List1),
                    V1 = if
                        is_map(M) -> M#{ <<>> => V };
                        true -> #{ <<>> => V }
                    end,
                    List2 = set_index(IndexNr, V1, List1, []),
                    Map#{ Key => List2 };
                [<<>>, Post] ->
                    % This was a key a.b[]$en which appends a new value if
                    % the post key was not known yet, otherwise it will set
                    % the post key on the current value.
                    MaybeList = maps:get(Key, Map, []),
                    List = case is_list(MaybeList) of
                        true -> MaybeList;
                        false -> []
                    end,
                    case List of
                        [ #{ Post := _ } | _ ] ->
                            Map#{ Key => [ #{ Post => V } | List ]};
                        [ M | L ] when is_map(M) ->
                            Map#{ Key => [ M#{ Post => V } | L ]};
                        _ ->
                            Map#{ Key => [ #{ Post => V } | List ]}
                    end;
                [Index, Post] ->
                    % This was a key a.b[123]$en which set the value if
                    % the post key was not known yet, otherwise appends
                    % a value to the list.
                    IndexNr = try
                                    max(1, binary_to_integer(Index))
                              catch _:_ ->
                                    append
                              end,
                    MaybeList = maps:get(Key, Map, []),
                    List = case is_list(MaybeList) of
                        true -> MaybeList;
                        false -> []
                    end,
                    List1 = lists:reverse(List),
                    M = get_index(IndexNr, List1),
                    V1 = if
                        is_map(M) -> M#{ Post => V };
                        true -> #{ Post => V }
                    end,
                    List2 = set_index(IndexNr, V1, List1, []),
                    Map#{ Key => List2 }
            end;
        _ ->
            Map#{ K => V }
    end;
nested_assign([ K | Ks ], V, Map) ->
    case binary:split(K, <<"[">>) of
        [Key, KIdxPost] ->
            case binary:split(KIdxPost, <<"]">>) of
                [<<>>, <<>>] ->
                    % This was a key 'a.b[].d' which sets a key in the last
                    % map in a list 'b'.
                    case maps:find(Key, Map) of
                        {ok, [ M | L ]} when is_map(M) ->
                            M1 = nested_assign(Ks, V, M),
                            Map#{ Key => [ M1 | L ]};
                        {ok, L} when is_list(L) ->
                            M1 = nested_assign(Ks, V, #{}),
                            Map#{ Key => [ M1 | L ]};
                        {ok, _}  ->
                            M1 = nested_assign(Ks, V, #{}),
                            Map#{ Key => [ M1 ]};
                        error ->
                            M1 = nested_assign(Ks, V, #{}),
                            Map#{ Key => [ M1 ]}
                    end;
                [Index, _Post] ->
                    % This was key a.b[123].d which sets a value on a specific
                    % index value in a list.
                    % Any special post value like the '$en' in a.b[123]$en.d'
                    % is ignored, as the are only allowed after the final key.
                    IndexNr = try
                                    max(1, binary_to_integer(Index))
                              catch _:_ ->
                                    append
                              end,
                    MaybeList = maps:get(Key, Map, []),
                    List = case is_list(MaybeList) of
                        true -> MaybeList;
                        false -> []
                    end,
                    List1 = lists:reverse(List),
                    M = get_index(IndexNr, List1),
                    M1 = if
                        is_map(M) -> M;
                        true -> #{}
                    end,
                    V1 = nested_assign(Ks, V, M1),
                    List2 = set_index(IndexNr, V1, List1, []),
                    Map#{ Key => List2 }
            end;
        _ when Ks =:= [] ->
            Map#{ K => V };
        _ when Ks =/= [] ->
            Sub = maps:get(K, Map, #{}),
            Sub1 = nested_assign(Ks, V, Sub),
            Map#{ K => Sub1 }
    end.

set_index(append, V, [], Acc) ->
    [V|Acc];
set_index(append, V, [VL|L], Acc) ->
    set_index(append, V, L, [VL|Acc]);
set_index(1, V, [], Acc) ->
    [V|Acc];
set_index(1, V, [_|L], Acc) ->
    lists:reverse(L, [V|Acc]);
set_index(N, V, [VL|L], Acc) when N > 1 ->
    set_index(N-1, V, L, [VL|Acc]);
set_index(N, V, [], Acc) when N > 1 ->
    set_index(N-1, V, [], [undefined|Acc]).

get_index(append, _L) -> #{};
get_index(1, [V|_]) -> V;
get_index(N, [_|L]) when N > 0 -> get_index(N-1, L);
get_index(_, [])-> #{}.

%% ---------------------------------------------------------------------------------------
%% Language handling
%% ---------------------------------------------------------------------------------------

%% @doc Combine properties like 'title$en' into #trans{} records.
combine_trans(Map) when is_map(Map) ->
    MapCombined = combine_trans_1( has_trans_prop(Map), Map ),
    maps:map(
        fun
            (_K, V) when is_list(V); is_map(V) ->
                combine_trans(V);
            (_K, V) ->
                V
        end,
        MapCombined);
combine_trans(List) when is_list(List) ->
    lists:map( fun combine_trans/1, List );
combine_trans(V) ->
    V.

has_trans_prop(Map) ->
    maps:fold(
        fun
            (K, V, false) -> is_trans_prop(K, V);
            (_, _, true) -> true
        end,
        false,
        Map).

combine_trans_1(true, Map) ->
    {TransParts, OtherProps} = maps:fold(
        fun(K, V, {Ts, Os}) ->
            case is_trans_prop(K, V) of
                true ->
                    Ts1 = [ {K,V} | Ts ],
                    {Ts1, Os};
                false ->
                    Os1 = Os#{ K => V },
                    {Ts, Os1}
            end
        end,
        {[], #{}},
        Map),
    TransPartsMap = group_trans_parts(TransParts),
    maps:merge(OtherProps, TransPartsMap);
combine_trans_1(false, Map) ->
    Map.

is_trans_prop(K, V) when is_binary(V) ->
    binary:match(K, <<"$">>) =/= nomatch;
is_trans_prop(_, _) ->
    false.

group_trans_parts(TransParts) ->
    lists:foldl(
        fun
            ({K, V}, Acc) ->
                case binary:split(K, <<"$">>, [global]) of
                    [ Name, Lang ] ->
                        case z_language:to_language_atom(Lang) of
                            {ok, Code} ->
                                add_trans(Name, Code, V, Acc);
                            {error, Reason} ->
                                ?LOG_NOTICE(#{
                                    text => <<"Dropping trans part, language code is unknown">>,
                                    in => zotonic_core,
                                    result => error,
                                    reason => Reason,
                                    language => Lang,
                                    part => K
                                }),
                                Acc
                        end;
                    _ ->
                        ?LOG_NOTICE(#{
                            text => <<"Dropping unknown trans part, should be like 'title$en'">>,
                            in => zotonic_core,
                            result => error,
                            reason => format,
                            part => K
                        }),
                        Acc
                end
        end,
        #{},
        TransParts).

add_trans(Name, Code, undefined, Acc) ->
    add_trans(Name, Code, <<>>, Acc);
add_trans(Name, Code, V, Acc) ->
    #trans{ tr = Tr } = maps:get(Name, Acc, #trans{}),
    Tr1 = [ {Code, z_string:trim(V)} | proplists:delete(Code, Tr) ],
    Acc#{ Name => #trans{ tr = Tr1 } }.

lift_singles(#{ <<>> := V } = M) when map_size(M) =:= 1 ->
    lift_singles(V);
lift_singles(M) when is_map(M) ->
    maps:map(fun(_K, V) -> lift_singles(V) end, M);
lift_singles(L) when is_list(L) ->
    lists:map(fun lift_singles/1, L);
lift_singles(V) ->
    V.

%% ---------------------------------------------------------------------------------------
%% Date handling
%% ---------------------------------------------------------------------------------------

%% @doc Combine multiple qs values to single dates.
combine_dates(Map, Now) when is_map(Map) ->
    MapCombined = combine_dates_1( has_dt_prop(Map), Map, Now ),
    maps:map(
        fun
            (_K, V) when is_list(V); is_map(V) ->
                combine_dates(V, Now);
            (_K, V) ->
                V
        end,
        MapCombined);
combine_dates(List, Now) when is_list(List) ->
    lists:map( fun(V) -> combine_dates(V, Now) end, List );
combine_dates(V, _Now) ->
    V.

has_dt_prop(Map) ->
    maps:fold(
        fun
            (_, _, true) -> true;
            (<<"dt:", _/binary>>, _V, false) -> true;
            (_, _, HasDT) -> HasDT
        end,
        false,
        Map).


%% @doc Combine multiple qs values to single dates.
combine_dates_1(true, Map, Now) ->
    {DateParts, OtherProps} = maps:fold(
        fun
            (<<"dt:", _/binary>> = K, V, {Ts, Os}) ->
                Ts1 = [ {K,V} | Ts ],
                {Ts1, Os};
            (K, V, {Ts, Os}) ->
                Os1 = Os#{ K => V },
                {Ts, Os1}
        end,
        {[], #{}},
        Map),
    ByName = group_date_parts(DateParts),
    DateKVs = combine_date_parts(ByName, Now),
    DateKVs1 = cleanup_dates(DateKVs),
    maps:merge(OtherProps, maps:from_list(DateKVs1));
combine_dates_1(false, Qs, _Now) ->
    Qs.

cleanup_dates(DateKVs) ->
    lists:map(
        fun(KV) ->
            cleanup_date_prop(KV)
        end,
        DateKVs).

cleanup_date_prop({Name, Date}) ->
    {Name, cleanup_date(Date)}.

cleanup_date(<<>>) -> undefined;
cleanup_date(null) -> undefined;
cleanup_date(undefined) -> undefined;
cleanup_date(false) -> undefined;
cleanup_date({{undefined, undefined, undefined}, _}) -> undefined;
cleanup_date(DateTime) -> DateTime.

group_date_parts(DatePartsQs) ->
    lists:foldl(
        fun
            ({K, V}, Acc) ->
                case binary:split(K, <<":">>, [global]) of
                    [ <<"dt">>, Pattern, EndFlag, Name ] ->
                        IsEnd = z_convert:to_bool(EndFlag),
                        group_date_part(Name, IsEnd, Pattern, V, Acc);
                    [ K ] ->
                        group_date_part(K, false, full, V, Acc);
                    _ ->
                        ?LOG_INFO(#{
                            text => <<"Dropping unknown date part, should be like 'dt:ymd:0:propname'">>,
                            in => zotonic_core,
                            result => error,
                            reason => format,
                            part => K
                        }),
                        Acc
                end
        end,
        #{},
        DatePartsQs).

group_date_part(Name, IsEnd, Pattern, V, Acc) ->
    group_date_part_1(basename(Name, IsEnd), Pattern, V, Acc).

group_date_part_1({IsEnd, Basename, Name}, Pattern, V, Acc) ->
    % Add to accumulator, combine all parts for Basename
    Parts = maps:get(Basename, Acc, #{}),
    {_, Patterns} = maps:get(IsEnd, Parts, {Name, []}),
    Patterns1 = [ {Pattern, V} | Patterns ],
    Parts1 = Parts#{
        IsEnd => {Name, Patterns1}
    },
    Acc#{ Basename => Parts1 }.


combine_date_parts(Parts, Now) ->
    maps:fold(
        fun(_, V, Acc) ->
            Ps = combine_date_part(V, Now),
            Ps ++ Acc
        end,
        [],
        Parts).

combine_date_part(#{ false := StartParts, true := EndParts }, Now) ->
    {StartKey, StartDate} = combine_part(StartParts, false, Now),
    {EndKey, EndDate} = combine_part(EndParts, true, Now),
    StartDate1 = set_default( default_date(StartKey, Now), StartDate ),
    EndDate1 = copy_missing(EndKey, StartDate1, EndDate),
    [
        {StartKey, StartDate1},
        {EndKey, EndDate1}
    ];
combine_date_part(#{ false := StartParts }, Now) ->
    {StartKey, StartDate} = combine_part(StartParts, false, Now),
    StartDate1 = set_default( default_date(StartKey, Now), StartDate ),
    [
        {StartKey, StartDate1}
    ];
combine_date_part(#{ true := EndParts }, Now) ->
    {EndKey, EndDate} = combine_part(EndParts, true, Now),
    EndDate1 = copy_missing(EndKey, default_date(EndKey, Now), EndDate),
    [
        {EndKey, EndDate1}
    ].

combine_part({Key, Ps}, _IsEnd, Now) ->
    DateParts = lists:map(
        fun({Pattern, V}) ->
            {Pattern, to_date_value(Pattern, V)}
        end,
        Ps),
    Date = lists:foldl(
        fun
            ({_Pattern, undefined}, DateAcc) ->
                DateAcc;
            ({_Pattern, <<>>}, DateAcc) ->
                DateAcc;
            ({Pattern, DatePart}, DateAcc) ->
                merge_date_part(DateAcc, Pattern, DatePart)
        end,
        default_date(Key, Now),
        DateParts),
    {Key, Date}.


default_date(<<"date">>, _Now) -> undefined_date();
default_date(<<"date_start">>, _Now) -> undefined_date();
default_date(<<"date_end">>, _Now) -> undefined_date();
default_date(<<"publication_start">>, _Now) -> undefined_date();
default_date(<<"publication_end">>, _Now) -> ?ST_JUTTEMIS;
default_date(<<"org_pubdate">>, _Now) -> undefined_date();
default_date(_, Now) -> Now.

undefined_date() ->
    { {undefined, undefined, undefined}, {undefined, undefined, undefined} }.

merge_date_part(_Date, full, V) -> V;
merge_date_part({{_Y, M, D}, {H, I, S}}, <<"y">>, V) -> {{V, M, D}, {H, I, S}};
merge_date_part({{Y, _M, D}, {H, I, S}}, <<"m">>, V) -> {{Y, V, D}, {H, I, S}};
merge_date_part({{Y, M, _D}, {H, I, S}}, <<"d">>, V) -> {{Y, M, V}, {H, I, S}};
merge_date_part({{Y, M, D}, {_H, I, S}}, <<"h">>, V) -> {{Y, M, D}, {V, I, S}};
merge_date_part({{Y, M, D}, {H, _I, S}}, <<"i">>, V) -> {{Y, M, D}, {H, V, S}};
merge_date_part({{Y, M, D}, {H, I, _S}}, <<"s">>, V) -> {{Y, M, D}, {H, I, V}};
merge_date_part({{Y, M, D}, {_H, _I, S}}, <<"hi">>, {H, I, _S}) -> {{Y, M, D}, {H, I, S}};
merge_date_part({{Y, M, D}, _Time}, <<"his">>, {_, _, _} = V) -> {{Y, M, D}, V};
merge_date_part({_Date, {H, I, S}}, <<"ymd">>, {_, _, _} = V) -> {V, {H, I, S}};
merge_date_part({_Date, {H, I, S}}, <<"dmy">>, {_, _, _} = V) -> {V, {H, I, S}}.


to_date_value(full, V) ->
    V;
to_date_value(<<"ymd">>, <<"-", V/binary>>) ->
    case to_date_value(<<"ymd">>, V) of
        {Y, M, D} when is_integer(Y) -> {-Y, M, D};
        YMD -> YMD
    end;
to_date_value(<<"dmy">>, V) ->
    case re:run(V, "([0-9]+)[-/: ]([0-9]+)[-/: ](-?[0-9]+)", [{capture, all_but_first, binary}]) of
        nomatch -> {undefined, undefined, undefined};
        % Negative years 13/7/-99
        {match, [D, M, Y]} -> {to_int(Y), to_int(M), to_int(D)}
    end;
to_date_value(Part, V) when Part =:= <<"ymd">>; Part =:= <<"his">> ->
    case binary:split(V, [<<"-">>, <<"/">>, <<":">>, <<" ">>], [global]) of
        [<<>>] -> {undefined, undefined, undefined};
        [Y, M, D] -> {to_int(Y), to_int(M), to_int(D)}
    end;
to_date_value(<<"hi">>, V) ->
    case binary:split(V, [<<"-">>, <<"/">>, <<":">>, <<" ">>], [global]) of
        [<<>>] -> {undefined, undefined, undefined};
        [H] -> {to_int(H), 0, undefined};
        [H, I] -> {to_int(H), to_int(I), undefined}
    end;
to_date_value(_, V) ->
    to_int(V).

copy_missing( <<"publication_end">>, _S, {{undefined,undefined,undefined},{undefined,undefined,_}} ) ->
    ?ST_JUTTEMIS;
copy_missing( _Name, _S, {{undefined,undefined,undefined},{undefined,undefined,_}} ) ->
    undefined;
copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{undefined,Me,De},{He,Ie,Se}} ) when is_integer(Ys) ->
    copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,Me,De},{He,Ie,Se}} );
copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,undefined,De},{He,Ie,Se}} ) when is_integer(Ms) ->
    copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}} ,{{Ys,Ms,De},{He,Ie,Se}} );
copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,Ms,undefined},{He,Ie,Se}} ) when is_integer(Ds) ->
    copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,Ms,Ds},{He,Ie,Se}} );
copy_missing( Name, S, {{undefined,Me,De},{He,Ie,Se}} ) ->
    copy_missing( Name, S, {{?EPOCH_END_YEAR,Me,De},{He,Ie,Se}} );
copy_missing( Name, S, {{Ye,undefined,De},{He,Ie,Se}} ) ->
    copy_missing( Name, S ,{{Ye,12,De},{He,Ie,Se}} );
copy_missing( Name, S, {{Ye,Me,undefined},{He,Ie,Se}} ) ->
    De = z_datetime:last_day_of_the_month(Ye,Me),
    copy_missing( Name, S, {{Ye,Me,De},{He,Ie,Se}} );
copy_missing( Name, S, {{Ye,Me,De},{undefined,Ie,Se}} ) ->
    copy_missing( Name, S, {{Ye,Me,De},{23,Ie,Se}} );
copy_missing( Name, S, {{Ye,Me,De},{He,undefined,Se}} ) ->
    copy_missing( Name, S, {{Ye,Me,De},{He,59,Se}} );
copy_missing( Name, S, {{Ye,Me,De},{He,Ie,undefined}} ) ->
    copy_missing( Name, S, {{Ye,Me,De},{He,Ie,59}} );
copy_missing( _Name, _S, E ) ->
    E.

set_default(Default, {{undefined, undefined, undefined}, {undefined, undefined, undefined}}) ->
    Default;
set_default(Default, {{undefined, undefined, undefined}, {0, 0, 0}}) ->
    Default;
set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{undefined,Me,De},{He,Ie,Se}} ) when is_integer(Ys) ->
    set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,Me,De},{He,Ie,Se}} );
set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ye,undefined,De},{He,Ie,Se}} ) when is_integer(Ms) ->
    set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ye,Ms,De},{He,Ie,Se}} );
set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ye,Me,undefined},{He,Ie,Se}} ) when is_integer(Ds) ->
    set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ye,Me,Ds},{He,Ie,Se}} );
set_default( S, {{undefined,Me,De},{He,Ie,Se}} ) ->
    set_default( S, {{?EPOCH_START_YEAR,Me,De},{He,Ie,Se}} );
set_default( S, {{Ye,undefined,De},{He,Ie,Se}} ) ->
    set_default( S, {{Ye,1,De},{He,Ie,Se}} );
set_default( S, {{Ye,Me,undefined},{He,Ie,Se}} ) ->
    set_default( S, {{Ye,Me,1},{He,Ie,Se}} );
set_default( S, {{Ye,Me,De},{undefined,Ie,Se}} ) ->
    set_default( S, {{Ye,Me,De},{0,Ie,Se}} );
set_default( S, {{Ye,Me,De},{He,undefined,Se}} ) ->
    set_default( S, {{Ye,Me,De},{He,0,Se}} );
set_default( S, {{Ye,Me,De},{He,Ie,undefined}} ) ->
    set_default( S, {{Ye,Me,De},{He,Ie,0}} );
set_default( _S, {{Ye,Me,De},{He,Ie,Se}} ) ->
    {{Ye,Me,De},{He,Ie,Se}};
set_default(_Default, Dt) ->
    Dt.

basename(Name, IsEnd) ->
    case has_suffix(Name, <<"_start">>) of
        true ->
            Length = size(Name) - size(<<"_start">>),
            <<Base:Length/binary, _/binary>> = Name,
            {false, Base, Name};
        false ->
            case has_suffix(Name, <<"_end">>) of
                true ->
                    Length = size(Name) - size(<<"_end">>),
                    <<Base:Length/binary, _/binary>> = Name,
                    {true, Base, Name};
                false ->
                    {IsEnd, Name, Name}
            end
    end.


has_suffix(B, Suffix) ->
    binary:longest_common_suffix([Suffix, B]) =:= size(Suffix).

to_int(<<>>) ->
    undefined;
to_int(A) ->
    try
        binary_to_integer(A)
    catch
        _:_ -> undefined
    end.



%% @doc Find all different language codes in the maps.
-spec extract_languages( map() ) -> [ atom() ].
extract_languages( Props ) when is_map(Props) ->
    Langs = maps:fold(fun extract_languages_1/3, #{}, Props),
    lists:sort( maps:keys(Langs) ).

extract_languages_1(_, V, Langs) when is_map(V) ->
    maps:fold(fun extract_languages_1/3, Langs, V);
extract_languages_1(_, V, Langs) when is_list(V) ->
    lists:foldl(
        fun(X, Acc) ->
            extract_languages_1(k, X, Acc)
        end,
        Langs,
        V);
extract_languages_1(_, #trans{ tr = Tr }, Langs) ->
    lists:foldl(
        fun
            ({Iso, _Trans}, Acc) ->
                case maps:is_key(Iso, Acc) of
                    false -> Acc#{ Iso => true };
                    true -> Acc
                end
        end,
        Langs,
        Tr);
extract_languages_1(_, _, Langs) ->
    Langs.


%% @doc Check all trans records, remove languages not mentioned.
-spec prune_languages(map(), list(atom())) -> map().
prune_languages(Props, Langs) ->
    prune_languages_1(Props, Langs).

prune_languages_1(M, Langs) when is_map(M) ->
    maps:map(
        fun(_K, V) -> prune_languages(V, Langs) end,
        M);
prune_languages_1(L, Langs) when is_list(L) ->
    lists:map(fun(E) -> prune_languages_1(E, Langs) end, L);
prune_languages_1(#trans{ tr = Tr }, Langs) ->
    Tr1 = lists:filter(
        fun({Iso, _}) -> lists:member(Iso, Langs) end,
        Tr),
    #trans{ tr = Tr1 };
prune_languages_1(V, _Langs) ->
    V.


%% @doc Normalize dates, ensure that all dates are in UTC
%%      and parsed to Erlang datetime format.
-spec normalize_dates( m_rsc:props(), boolean(), binary()|undefined ) -> m_rsc:props().
normalize_dates(#{ <<"tz">> := Tz } = Props, IsAllDay, undefined) ->
    normalize_dates_1(Props, IsAllDay, Tz);
normalize_dates(Props, IsAllDay, Tz) ->
    normalize_dates_1(Props, IsAllDay, Tz).

normalize_dates_1(Props, IsAllDay, undefined) ->
    normalize_dates_2(Props, IsAllDay, <<"UTC">>);
normalize_dates_1(Props, IsAllDay, Tz) ->
    normalize_dates_2(Props, IsAllDay, Tz).

normalize_dates_2(M, IsAllDay, Tz) when is_map(M) ->
    maps:map(
        fun(K, V) ->
            case is_date_key(K) orelse is_date_value(V) of
                true ->
                    try
                        norm_date(K, V, IsAllDay, Tz)
                    catch
                        _:_ -> undefined
                    end;
                false ->
                    V
            end
        end,
        M);
normalize_dates_2(L, IsAllDay, Tz) when is_list(L) ->
    lists:map(
        fun(V) -> normalize_dates_2(V, IsAllDay, Tz) end,
        L);
normalize_dates_2(V, IsAllDay, Tz) ->
    case is_date_value(V) of
        true ->
            try
                norm_date(<<>>, V, IsAllDay, Tz)
            catch
                _:_ -> undefined
            end;
        false ->
            V
    end.

norm_date(_K, undefined, _IsAllDay, _Tz) ->
    undefined;
norm_date(_K, <<>>, _IsAllDay, _Tz) ->
    undefined;
norm_date(_K, V, _IsAllDay, _Tz) when is_integer(V) ->
    z_datetime:timestamp_to_datetime(V);
norm_date(K, {{Y,M,D}, {H,I,S}} = DT, true, Tz) when
    is_integer(Y), is_integer(M), is_integer(D),
    is_integer(H), is_integer(I), is_integer(S) ->
    case K of
        <<"date_start">> -> DT;
        <<"date_end">> -> DT;
        _ -> z_datetime:to_utc(DT, Tz)
    end;
norm_date(_K, {{Y,M,D}, {H,I,S}} = DT, false, Tz) when
    is_integer(Y), is_integer(M), is_integer(D),
    is_integer(H), is_integer(I), is_integer(S) ->
    z_datetime:to_utc(DT, Tz);
norm_date(K, V, true, Tz) ->
    case K of
        <<"date_start">> -> z_datetime:to_datetime(V, <<"UTC">>);
        <<"date_end">> -> z_datetime:to_datetime(V, <<"UTC">>);
        _ -> z_datetime:to_datetime(V, Tz)
    end;
norm_date(_K, V, false, Tz) ->
    z_datetime:to_datetime(V, Tz).


is_date_value({{Y,M,D}, {H,I,S}}) when
    is_integer(Y), is_integer(M), is_integer(D),
    is_integer(H), is_integer(I), is_integer(S) ->
    true;
is_date_value(_) ->
    false.

is_date_key(<<"is_", _/binary>>) -> false;
is_date_key(<<"date_is_", _/binary>>) -> false;
is_date_key(<<"date_remarks">>) -> false;
is_date_key(<<"date_", _/binary>>) -> true;
is_date_key(<<"org_pubdate">>) -> true;
is_date_key(<<"publication_start">>) -> true;
is_date_key(<<"publication_end">>) -> true;
is_date_key(K) when is_binary(K) ->
    case binary:longest_common_suffix([ K, <<"_date">> ]) of
        5 -> true;
        _ -> false
    end;
is_date_key(_) -> false.