src/models/m_rsc_import.erl

%% @author Arjan Scherpenisse <arjan@scherpenisse.net>
%% @copyright 2010-2021 Arjan Scherpenisse, Marc Worrell
%% @doc Importing non-authoritative things exported by m_rsc_export into the system.

%% 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(m_rsc_import).
-author("Arjan Scherpenisse <arjan@scherpenisse.net>").

-behaviour(zotonic_model).

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

-export([
    m_get/3,

    mark_imported/3,

    fetch_preview/2,

    is_imported/2,

    get_import_status/2,
    set_import_status/3,

    import/2,
    import/3,
    import_uri/2,
    import_uri/3,
    import_uri_recursive/3,
    import_uri_recursive_async/3,

    reimport/2,
    reimport/4,
    reimport_recursive/2,
    reimport_recursive/4,
    reimport_recursive_async/2,

    update_medium_uri/4,

    install/1
]).

-export([
    import_referred_ids_task/3
]).

-type option() :: {props_forced, map()}                 % Properties overlayed over the imported properties
                | {props_default, map()}                % Default properties
                | {import_edges, non_neg_integer()}     % Number of edge-redirections to import
                | is_import_deleted                     % Set if deleted resources must be re-imported
                | {is_import_deleted, boolean()}
                | is_authoritative                      % If set then make a local copy, do not save uri
                | {is_authoritative, boolean()}
                | {allow_category, [ binary() ]}
                | {allow_predicate, [ binary() ]}
                | {deny_category, [ binary() ]}
                | {deny_predicate, [ binary() ]}
                | {fetch_options, z_url_fetch:options()}
                | {uri_template, binary()}.
-type options() :: [ option() ].

-type import_result() :: {ok, {m_rsc:resource_id(), import_map()}}
                       | {error, term()}.

-type import_map() :: #{ binary() => m_rsc:resource_id() }.

-export_type([
    options/0,
    option/0,
    import_result/0,
    import_map/0
]).


-spec m_get( list(), zotonic_model:opt_msg(), z:context() ) -> zotonic_model:return().
m_get([ <<"fetch_raw">> | Rest ], #{ payload := Uri }, Context) ->
    case z_auth:is_auth(Context) of
        true ->
            case fetch_raw(Uri, Context) of
                {ok, JSON} ->
                    {ok, {JSON, Rest}};
                {error, _} = Error ->
                    Error
            end;
        false ->
            {error, eacces}
    end.


%% @doc Check if a resource has been succesfully imported.
-spec is_imported( m_rsc:resource(), z:context() ) -> boolean().
is_imported( Rsc, Context ) ->
    case m_rsc:rid(Rsc, Context) of
        undefined ->
            false;
        RscId ->
            case z_db:q1("
                select last_import_status
                from rsc_import
                where id = $1",
                [ RscId ],
                Context)
            of
                <<"ok">> -> true;
                _ -> false
            end
    end.


%% @doc Fetch the import status of a resource.
-spec get_import_status( m_rsc:resource(), z:context() ) -> {ok, map()} | {error, term()}.
get_import_status( Rsc, Context ) ->
    case m_rsc:rid(Rsc, Context) of
        undefined ->
            {error, enoent};
        RscId ->
            case z_acl:rsc_visible(RscId, Context) of
                true ->
                    z_db:select(rsc_import, RscId, Context);
                false ->
                    {error, eacces}
            end
    end.

%% @doc Modify the import options of the resource.
-spec set_import_status( m_rsc:resource(), map(), z:context() ) -> {ok, m_rsc:resource_id()} | {error, term()}.
set_import_status(Rsc, Status, Context) when is_map(Status) ->
    case m_rsc:rid(Rsc, Context) of
        undefined ->
            {error, enoent};
        RscId ->
            case z_acl:rsc_editable(RscId, Context) of
                true ->
                    Status1 = maps:fold(
                        fun
                            (K, V, Acc) when is_binary(K) ->
                                Acc#{ K => V };
                            (_, _, Acc) ->
                                Acc
                        end,
                        #{},
                        Status),
                    Status2 = maps:without([ <<"id">>, <<"created">>, <<"props">> ], Status1),
                    case maps:find(<<"user_id">>, Status1) of
                        {ok, UId} ->
                            case z_acl:rsc_editable(UId, Context) of
                                true ->
                                    z_db:update(rsc_import, RscId, Status2, Context);
                                false ->
                                    {error, eacces}
                            end;
                        error ->
                            z_db:update(rsc_import, RscId, Status2, Context)
                    end;
                false ->
                    {error, eacces}
            end
    end.


%% @doc Mark a resource as imported, set the import result.
-spec mark_imported( m_rsc:resource_id(), atom() | binary() | string(), z:context() ) -> ok | {error, enoent}.
mark_imported(RscId, Status, Context) ->
    case z_db:q("
        update rsc_import
        set last_import_date = now(),
            last_import_status = $1
        where id = $2",
        [ z_convert:to_binary(Status), RscId ],
        Context)
    of
        1 -> ok;
        0 -> {error, enoent}
    end.


%% @doc Find or create a placeholder resource for later import of referred ids.
-spec maybe_create_empty( map(), map(), options(), z:context() ) -> {ok, {m_rsc:rescource_id(), map()}} | {error, term()}.
maybe_create_empty(Rsc, ImportedAcc, Options, Context) ->
    case is_imported_resource(Rsc, ImportedAcc, Options, Context) of
        false ->
            Uri = maps:get(<<"uri">>, Rsc),
            case find_allowed_category(Rsc, #{}, Options, Context) of
                {ok, Cat} ->
                    Props = #{
                        <<"category_id">> => Cat,
                        <<"is_published">> => false,
                        <<"title">> => maps:get(<<"title">>, Rsc, undefined),
                        <<"name">> => maps:get(<<"name">>, Rsc, undefined)
                    },
                    Props2 = case proplists:get_value(is_authoritative, Options, false) of
                        true ->
                            Props#{
                                <<"is_authoritative">> => true,
                                <<"uri">> => undefined
                            };
                        false ->
                            Props#{
                                <<"is_authoritative">> => false,
                                <<"uri">> => Uri
                            }
                    end,
                    UpdateOptions = [
                        {is_escape_texts, false},
                        is_import
                    ],
                    InsertResult = z_db:transaction(fun(Ctx) ->
                        case m_rsc:insert(Props2, UpdateOptions, Context) of
                            {ok, NewId} ->
                                Import = #{
                                    <<"id">> => NewId,
                                    <<"uri">> => Uri,
                                    <<"host">> => host(Uri),
                                    <<"user_id">> => z_acl:user(Ctx),
                                    <<"options">> => Options
                                },
                                z_db:insert(rsc_import, Import, Context);
                            {error, _} = Error ->
                                Error
                        end
                    end, Context),
                    case InsertResult of
                        {ok, LocalId} ->
                            ImportedAcc1 = ImportedAcc#{
                                Uri => LocalId
                            },
                            {ok, {LocalId, ImportedAcc1}};
                        {error, duplicate_page_path} ->
                            PagePath = unique_page_path( maps:get(<<"page_path">>, Rsc), Context ),
                            ?LOG_WARNING(#{
                                text => <<"Import of duplicate page_path">>,
                                in => zotonic_core,
                                result => error,
                                reason => duplicate_page_path,
                                uri => Uri,
                                page_path => PagePath
                            }),
                            Rsc1 = Rsc#{ <<"page_path">> => PagePath },
                            maybe_create_empty(Rsc1, ImportedAcc, Options, Context);
                        {error, duplicate_name} ->
                            Name = unique_name( maps:get(<<"name">>, Rsc), Context ),
                            ?LOG_WARNING(#{
                                text => <<"Import of duplicate name">>,
                                in => zotonic_core,
                                result => error,
                                reason => duplicate_name,
                                uri => Uri,
                                old_name => maps:get(<<"name">>, Props2, undefined),
                                new_name => Name
                            }),
                            Rsc1 = Rsc#{ <<"name">> => Name },
                            maybe_create_empty(Rsc1, ImportedAcc, Options, Context);
                        {error, Reason} = Error ->
                            ?LOG_NOTICE(#{
                                text => <<"Not importing menu entry from remote">>,
                                in => zotonic_core,
                                result => error,
                                reason => Reason,
                                uri => Uri,
                                category => Cat
                            }),
                            Error
                    end;
                {error, Reason} = Error  ->
                    % Unknown category, deny access
                    ?LOG_INFO(#{
                        text => <<"Not importing menu entry from remote, category disallowed">>,
                        in => zotonic_core,
                        result => error,
                        reason => Reason,
                        uri => Uri,
                        rsc => Rsc
                    }),
                    Error
            end;
        {true, RscId} ->
            {ok, {RscId, ImportedAcc}}
    end.

is_imported_resource(Rsc, ImportedAcc, Options, Context) ->
    Uri = uri(Rsc),
    case maps:find(Uri, ImportedAcc) of
        {ok, LocalId} ->
            {true, LocalId};
        error ->
            case m_rsc:rid(Rsc, Context) of
                undefined ->
                    false;
                RId ->
                    case proplists:get_value(is_authoritative, Options, false) of
                        true ->
                            % A local copy was requested but the one present is
                            % not authoritative.
                            case m_rsc:p_no_acl(RId, is_authoritative, Context) of
                                true -> {true, RId};
                                false -> false
                            end;
                        false ->
                            {true, RId}
                    end
            end
    end.


%% @doc Reimport a non-authoritative resource or placeholder using the saved import flags.
-spec reimport_recursive( m_rsc:resource_id(), z:context() ) -> import_result().
reimport_recursive(Id, Context) ->
    reimport_recursive(Id, #{}, saved, Context).

%% @doc Reimport a non-authoritative resource or placeholder using the saved import flags.
-spec reimport_recursive( m_rsc:resource_id(), map(), options() | saved, z:context() ) -> import_result().
reimport_recursive(Id, RefIds, Options, Context) ->
    case reimport(Id, RefIds, Options, Context) of
        {ok, {LocalId, RefIds1}} ->
            Imported = maps:fold(
                fun(_Uri, LId, ImpAcc) ->
                    ImpAcc#{ LId => true }
                end,
                #{ LocalId => true },
                RefIds),
            RefIds2 = import_referred_ids(RefIds1, Imported, Context),
            {ok, {LocalId, RefIds2}};
        {error, _} = Error ->
            Error
    end.

%% @doc Reimport a non-authoritative resource or placeholder using the saved import flags, async
%% reimport of all objects.
-spec reimport_recursive_async( m_rsc:resource_id(), z:context() ) -> import_result().
reimport_recursive_async(Id, Context) ->
    case reimport(Id, Context) of
        {ok, {LocalId, RefIds}} ->
            ContextAsync = z_context:prune_for_async(Context),
            sidejob_supervisor:spawn(
                    zotonic_sidejobs,
                    {?MODULE, import_referred_ids_task, [ RefIds, #{ LocalId => true }, ContextAsync ]}),
            {ok, {LocalId, RefIds}};
        {error, _} = Error ->
            Error
    end.

-spec import_referred_ids_task( map(), map(), z:context() ) -> ok.
import_referred_ids_task(RefIds, ImportedIds, Context) ->
    _ = import_referred_ids(RefIds, ImportedIds, Context),
    ok.

%% @doc Recursively import all resources connected to the given resources. Return a map
%% of local resource ids that are imported or referred.
import_referred_ids(RefIds, ImportedIds, Context) ->
    {NewRefIds, NewImportedIds} = maps:fold(
        fun(Uri, LocalId, {ImpAcc, ImpIdsAcc}) ->
            case maps:is_key(LocalId, ImpIdsAcc) of
                true ->
                    {ImpAcc, ImpIdsAcc};
                false ->
                    ImpAcc2 = case is_imported(LocalId, Context) of
                        true ->
                            ImpAcc#{
                                Uri => LocalId
                            };
                        false ->
                            case reimport_1(LocalId, ImpAcc, false, Context) of
                                {ok, {NewLocalId, ImpAcc1}} ->
                                    ImpAcc1#{
                                        Uri => NewLocalId
                                    };
                                {error, _} ->
                                    ImpAcc#{
                                        Uri => LocalId
                                    }
                            end
                    end,
                    {ImpAcc2, ImpIdsAcc#{ LocalId => true }}
            end
        end,
        {RefIds, ImportedIds},
        RefIds),
    OldCount = maps:size(RefIds),
    NewCount = maps:size(NewRefIds),
    case NewCount of
        OldCount -> NewRefIds;
        _ -> import_referred_ids(NewRefIds, NewImportedIds, Context)
    end.


% %% @doc Check if a resource is pending an import.
% is_import_pending(LocalId, Context) ->
%     case get_import_status(LocalId, Context) of
%         {ok, #{ <<"last_import_status">> := <<"ok">> }} ->
%             false;
%         _ ->
%             not m_rsc:p(LocalId, is_authoritative, Context)
%     end.


%% @doc Reimport a non-authoritative resource or placeholder using the saved import flags.
-spec reimport( m_rsc:resource_id(), z:context() ) -> import_result().
reimport(Id, Context) ->
    reimport_1(Id, #{}, true, Context).

reimport_1(Id, ImportedAcc, IsForceImport, Context) ->
    case z_db:select(rsc_import, Id, Context) of
        {ok, #{
            <<"options">> := Options,
            <<"uri">> := Uri,
            <<"last_import_status">> := Status
        }} ->
            case IsForceImport
                orelse (
                    not m_rsc:p(Id, is_authoritative, Context)
                    orelse Status =/= <<"ok">>
                )
            of
                true ->
                    case fetch_json(Uri, Context) of
                        {ok, JSON} ->
                            import_data(Id, Uri, JSON, ImportedAcc, Options, Context);
                        {error, _} = Error ->
                            Error
                    end;
                false ->
                    {ok, {Id, ImportedAcc}}
            end;
        {error, enoent} ->
            reimport_nonauth(Id, ImportedAcc, Context);
        {error, _} = Error ->
            Error
    end.

reimport_nonauth(Id, ImportedAcc, Context) ->
    case m_rsc:p_no_acl(Id, is_authoritative, Context) of
        false ->
            case m_rsc:p_no_acl(Id, uri_raw, Context) of
                <<>> ->
                    {error, uri};
                Uri ->
                    case fetch_json(Uri, Context) of
                        {ok, JSON} ->
                            import_data(Id, Uri, JSON, ImportedAcc, [], Context);
                        {error, _} = Error ->
                            Error
                    end
            end;
        true ->
            {error, authoritative}
    end.

%% @doc Reimport a non-authoritative resource or placeholder using new import options.
-spec reimport( m_rsc:resource_id(), map(), options() | saved, z:context() ) -> import_result().
reimport(Id, RefIds, Options, Context) ->
    {Uri, Options1} = case z_db:select(rsc_import, Id, Context) of
        {ok, #{
            <<"uri">> := ImportUri,
            <<"options">> := SavedOptions
        }} when Options =:= saved ->
            {ImportUri, SavedOptions};
        {ok, #{
            <<"uri">> := ImportUri
        }} ->
            {ImportUri, Options};
        {error, _} ->
            m_rsc:p(Id, uri_raw, Context)
    end,
    case fetch_json(Uri, Context) of
        {ok, JSON} ->
            import_data(Id, Uri, JSON, RefIds, Options1, Context);
        {error, _} = Error ->
            Error
    end.


-spec update_medium_uri( m_rsc:resource_id(), string() | binary(), options(), z:context() ) -> {ok, m_rsc:resource_id()}.
update_medium_uri(LocalId, Uri, Options, Context) ->
    case z_acl:rsc_editable(LocalId, Context) of
        true ->
            case fetch_json(Uri, Context) of
                {ok, JSON} ->
                    maybe_import_medium(LocalId, JSON, Options, Context);
                {error, _} = Error ->
                    Error
            end;
        false ->
            {error, eacces}
    end.


fetch_json(undefined, _Context) ->
    {error, uri};
fetch_json(Uri, Context) ->
    Site = z_context:site(Context),
    case z_sites_dispatcher:get_site_for_url(Uri) of
        {ok, Site} ->
            {error, local};
        _ ->
            % Check other modules if they can return the JSON for the uri.
            case z_notifier:first(#rsc_import_fetch{ uri = Uri }, Context) of
                {ok, #{
                    <<"uri">> := U,
                    <<"resource">> := Rsc
                }} = Data when is_binary(U), is_map(Rsc) ->
                    {ok, #{
                        <<"status">> => <<"ok">>,
                        <<"result">> => Data
                    }};
                {ok, Data} ->
                    {ok, Data};
                {error, Reason} = Error ->
                    ?LOG_WARNING(#{
                        text => <<"Error fetching resource">>,
                        in => zotonic_core,
                        result => error,
                        reason => Reason,
                        uri => Uri
                    }),
                    Error;
                undefined ->
                    Options = [
                        {accept, "application/json"},
                        {user_agent, "Zotonic"}
                    ],
                    case z_fetch:fetch(Uri, Options, Context) of
                        {ok, {_FinalUrl, _Hs, _Size, Body}} ->
                            JSON = jsxrecord:decode(Body),
                            {ok, JSON};
                        {error, Reason} = Error ->
                            ?LOG_WARNING(#{
                                text => <<"Error fetching resource for import">>,
                                in => zotonic_core,
                                result => error,
                                reason => Reason,
                                uri => Uri
                            }),
                            Error
                    end
            end
    end.


%% @doc Fetch a raw version of the resource at the Url. No sanitization.
-spec fetch_raw(Uri :: binary() | map(), z:context()) -> {ok, map()} | {error, term()}.
fetch_raw(Uri, Context) when is_binary(Uri) ->
    case fetch_json(Uri, Context) of
        {ok, #{
            <<"status">> := <<"ok">>,
            <<"result">> := #{
                <<"id">> := _RemoteId,
                <<"is_a">> := _IsA,
                <<"resource">> := Rsc,
                <<"uri">> := _Uri
            } = Result
        }} when is_map(Rsc) ->
            {ok, Result};
        {ok, _} ->
            {error, format};
        {error, _} = Error ->
            Error
    end;
fetch_raw(#{ <<"uri">> := Uri }, Context) ->
    fetch_raw(Uri, Context);
fetch_raw(_, _Context) ->
    {error, enoent}.


%% @doc Fetch a sanitized version of the resource at the Url. Without edges, mapping of embedded
%% ids etc. This is to be used as a simple and quick preview of the resource at the given Uri.
-spec fetch_preview( string() | binary(), z:context() ) -> {ok, m_rsc:props()} | {error, term()}.
fetch_preview(Url, Context) ->
    case fetch_raw(Url, Context) of
        {ok, #{
            <<"is_a">> := IsA,
            <<"resource">> := Rsc,
            <<"uri">> := Uri
        } = JSON} when is_map(Rsc) ->
            case is_local_site(Uri, Context) of
                true ->
                    {error, local};
                false ->
                    RId = #{
                        <<"is_a">> => IsA
                    },
                    Category = find_category(RId, Rsc, Context),
                    Rsc1 = z_sanitize:escape_props_check(Rsc, Context),
                    Rsc2 = Rsc1#{
                        <<"is_authoritative">> => false,
                        <<"uri">> => z_sanitize:uri(Uri),
                        <<"category_id">> => Category
                    },
                    % The URL might need to be fetched as a data: url, as the remote
                    % resource might have non-anonymous access permissions.
                    Result = #{
                        <<"resource">> => Rsc2,
                        <<"depiction_url">> => maps:get(<<"depiction_url">>, JSON, undefined)
                    },
                    {ok, Result}
            end;
        {ok, _} ->
            {error, format};
        {error, _} = Error ->
            Error
    end.


import_data(Id, _Url, #{ <<"status">> := <<"ok">>, <<"result">> := JSON }, ImportedAcc, Options, Context) when is_map(JSON) ->
    import(Id, JSON, ImportedAcc, Options, Context);
import_data(_Id, Url, #{ <<"status">> := <<"error">> } = JSON, _ImportedAcc, _Options, _Context) ->
    ?LOG_WARNING(#{
        text => <<"Remote returned error on import">>,
        in => zotonic_core,
        uri => Url,
        json => JSON
    }),
    {error, remote};
import_data(_Id, _Url, #{ <<"rdf_triples">> := [] }, _ImportedAcc, _Options, _Context) ->
    {error, nodoc};
import_data(Id, Url, #{ <<"rdf_triples">> := _ } = Data, ImportedAcc, Options, Context) ->
    import_rdf(Id, Url, Data, ImportedAcc, Options, Context);
import_data(_Id, Url, JSON, _ImportedAcc, _Options, _Context) ->
    ?LOG_WARNING(#{
        text => <<"Import of JSON with unknown structure">>,
        in => zotonic_core,
        uri => Url,
        json => JSON
    }),
    {error, status}.

import_rdf(OptLocalId, OptUri, #{ <<"rdf_triples">> := Triples } = Data, ImportedAcc, Options, Context) ->
    DataUri = maps:get(<<"uri">>, Data, OptUri),
    Docs = zotonic_rdf:triples_to_docs(Triples),
    case lists:map(fun zotonic_rdf:compact/1, Docs) of
        [] ->
            {error, enoent};
        CDocs ->
            UriDocs = lists:filter(fun(Doc) -> maps:get(<<"@id">>, Doc) =:= DataUri end, CDocs),
            {MainDoc, OtherDocs} = case UriDocs of
                [D] -> {D, CDocs -- [D]};
                [] -> {hd(CDocs), tl(CDocs)}
            end,
            MainUri = maps:get(<<"@id">>, MainDoc),
            case import_doc(OptLocalId, MainDoc, ImportedAcc, Options, Context) of
                {ok, {LocalId, Acc1}} ->
                    ImportedAcc1 = Acc1#{ MainUri => LocalId },
                    ImportedAcc2 = lists:foldl(
                        fun(D, ImpAcc) ->
                            case import_doc(undefined, D, ImpAcc, Options, Context) of
                                {ok, {Id, ImpAcc1}} ->
                                    DUri = maps:get(<<"@id">>, D),
                                    ImpAcc1#{ DUri => Id };
                                {error, _} ->
                                    ImpAcc
                            end
                        end,
                        ImportedAcc1,
                        OtherDocs),
                    {ok, {maps:get(MainUri, ImportedAcc2, undefined), ImportedAcc2}};
                {error, _} = Error ->
                    Error
            end
    end.

import_doc(OptLocalId, Doc, ImpAcc, Options, Context) ->
    case z_rdf_props:extract_resource(Doc, Context) of
        {ok, Rsc} when is_map(Rsc) ->
            Uri = maps:get(<<"uri">>, Rsc),
            OptId1 = case OptLocalId of
                undefined -> m_rsc:rid(Uri, Context);
                _ -> OptLocalId
            end,
            import(OptId1, Rsc, ImpAcc, Options, Context);
        {error, _} = Error ->
            Error
    end.

%% @doc Import a non-authoritative resource from a remote URI using default import options.
-spec import_uri( string() | binary(), z:context() ) -> import_result().
import_uri(Uri, Context) ->
    import_uri(Uri, [], Context).

%% @doc Import a non-authoritative resource from a remote URI.
-spec import_uri( string() | binary(), options(), z:context() ) -> import_result().
import_uri(Uri, Options, Context) ->
    case fetch_json(Uri, Context) of
        {ok, JSON} ->
            import_data(undefined, Uri, JSON, #{}, Options, Context);
        {error, _} = Error ->
            Error
    end.

%% @doc Recursive import of resources.
-spec import_uri_recursive( string() | binary(), options(), z:context() ) -> import_result().
import_uri_recursive(Uri, Options, Context) ->
    case import_uri(Uri, Options, Context) of
        {ok, {LocalId, RefIds}} ->
            RefIds1 = import_referred_ids(RefIds, [], Context),
            {ok, {LocalId, RefIds1}};
        {error, _} = Error ->
            Error
    end.

%% @doc Recursive import of resources, async import of all referred ids.
-spec import_uri_recursive_async( string() | binary(), options(), z:context() ) -> import_result().
import_uri_recursive_async(Uri, Options, Context) ->
    case import_uri(Uri, Options, Context) of
        {ok, {LocalId, RefIds}} ->
            ContextAsync = z_context:prune_for_async(Context),
            sidejob_supervisor:spawn(
                    zotonic_sidejobs,
                    {?MODULE, import_referred_ids_task, [ RefIds, #{ LocalId => true }, ContextAsync ]}),
            {ok, {LocalId, RefIds}};
        {error, _} = Error ->
            Error
    end.

%% @doc Import a resource using default import options.
-spec import( map(), z:context() ) -> import_result().
import(JSON, Context) ->
    import(JSON, [], Context).

%% @doc Import a resource. If the resource already exists then it must be non-authoritative
%%      and have a matching URI. The resource to be updated is looked up by matching either the
%%      URI or the unique name. If the unique name matches then the category of the existing
%%      resource must have an overlap with the category of the imported resource.
-spec import( map(), options(), z:context() ) -> import_result().
import(JSON, Options, Context) ->
    import(undefined, JSON, #{}, Options, Context).

import(OptLocalId, #{
        <<"resource">> := Rsc,
        <<"uri">> := Uri
    } = JSON, ImportedAcc, Options, Context) ->

    ?LOG_INFO(#{
        text => <<"Importing resource">>,
        in => zotonic_core,
        uri => Uri,
        local_id => OptLocalId
    }),
    RemoteRId = #{
        <<"uri">> => Uri,
        <<"name">> => maps:get(<<"name">>, JSON, undefined),
        <<"is_a">> => maps:get(<<"is_a">>, JSON, [])
    },
    case is_local_site(Uri, Context) of
        true ->
            case m_rsc:rid(Uri, Context) of
                undefined -> {error, enoent};
                Id -> {ok, {Id, ImportedAcc}}
            end;
        false ->
            case find_allowed_category(RemoteRId, Rsc, Options, Context) of
                {ok, Category} ->
                    UriTemplate = maps:get(<<"uri_template">>, JSON, proplists:get_value(uri_template, Options)),
                    {Rsc1, ImportedAcc1} = cleanup_map_ids(RemoteRId, Rsc, UriTemplate, ImportedAcc, Options, Context),
                    Rsc2 = Rsc1#{
                        <<"category_id">> => Category
                    },
                    case update_rsc(OptLocalId, RemoteRId, Rsc2, ImportedAcc1, Options, Context) of
                        {ok, LocalId} ->
                            ImportedAcc2 = ImportedAcc1#{
                                Uri => LocalId
                            },
                            _ = maybe_import_medium(LocalId, JSON, Options, Context),
                            ImportedAcc3 = case proplists:get_value(import_edges, Options, 0) of
                                N when is_integer(N), N > 0 ->
                                    PropsForced = proplists:get_value(props_forced, Options, #{}),
                                    EdgeOptions = [
                                        {import_edges, N - 1},
                                        {props_forced, maps:remove(<<"category_id">>, PropsForced)}
                                        | proplists:delete(import_edges,
                                            proplists:delete(props_forced, Options))
                                    ],
                                    import_edges(LocalId, JSON, ImportedAcc2, EdgeOptions, Context);
                                _ ->
                                    ImportedAcc2
                            end,
                            case mark_imported(LocalId, ok, Context) of
                                ok ->
                                    ok;
                                {error, enoent} ->
                                    Import = #{
                                        <<"id">> => LocalId,
                                        <<"uri">> => Uri,
                                        <<"host">> => host(Uri),
                                        <<"user_id">> => z_acl:user(Context),
                                        <<"options">> => Options,
                                        <<"last_import_date">> => calendar:universal_time(),
                                        <<"last_import_status">> => <<"ok">>
                                    },
                                    z_db:insert(rsc_import, Import, Context)
                            end,
                            {ok, {LocalId, ImportedAcc3}};
                        {error, Reason} = Error ->
                            ?LOG_INFO(#{
                                text => <<"Importing resource returned error">>,
                                in => zotonic_core,
                                result => error,
                                reason => Reason,
                                uri => Uri,
                                rsc_id => OptLocalId
                            }),
                            Error
                    end;
                {error, _} = Error ->
                    Error
            end
    end;
import(OptLocalId, #{ <<"rdf_triples">> := _ } = Data, ImportedAcc, Options, Context) ->
    Uri = maps:get(<<"uri">>, Data, m_rsc:p(OptLocalId, uri_raw, Context)),
    import_rdf(OptLocalId, Uri, Data, ImportedAcc, Options, Context);
import(_OptLocalId, JSON, _ImportedAcc, _Options, _Context) ->
    ?LOG_WARNING(#{
        text => <<"Import of JSON without required fields resource and uri">>,
        in => zotonic_core,
        json => JSON
    }),
    {error, status}.


update_rsc(OptLocalId, RemoteRId, Rsc, ImportedAcc, Options, Context) ->
    case update_rsc_1(OptLocalId, RemoteRId, Rsc, ImportedAcc, Options, Context) of
        {ok, _} = OK ->
            OK;
        {error, duplicate_page_path} ->
            OldPath = maps:get(<<"page_path">>, Rsc),
            PagePath = unique_page_path(OldPath, Context),
            ?LOG_WARNING(#{
                text => <<"Import of duplicate page_path">>,
                in => zotonic_core,
                result => error,
                reason => duplicate_page_path,
                rsc_id => OptLocalId,
                remote_id => RemoteRId,
                old_page_path => OldPath,
                new_page_path => PagePath,
                uri => maps:get(<<"uri">>, Rsc, undefined)
            }),
            Rsc1 = Rsc#{ <<"page_path">> => PagePath },
            update_rsc(OptLocalId, RemoteRId, Rsc1, ImportedAcc, Options, Context);
        {error, duplicate_name} ->
            OldName = maps:get(<<"name">>, Rsc),
            Name = unique_name(OldName, Context),
            ?LOG_WARNING(#{
                text => <<"Import of duplicate name">>,
                in => zotonic_core,
                result => error,
                reason => duplicate_name,
                rsc_id => OptLocalId,
                remote_id => RemoteRId,
                old_name => OldName,
                new_name => Name,
                uri => maps:get(<<"uri">>, Rsc, undefined)
            }),
            Rsc1 = Rsc#{ <<"name">> => Name },
            update_rsc(OptLocalId, RemoteRId, Rsc1, ImportedAcc, Options, Context);
        {error, _} = Error ->
            Error
    end.

update_rsc_1(undefined, RemoteRId, Rsc, ImportedAcc, Options, Context) ->
    RscLang = ensure_language_prop(Rsc),
    UpdateOptions = [
        {is_escape_texts, false},
        is_import
    ],
    Uri = maps:get(<<"uri">>, RscLang),
    IsImportDeleted = proplists:get_value(is_import_deleted, Options, false),
    case is_imported_resource(RemoteRId, ImportedAcc, Options, Context) of
        false when IsImportDeleted ->
            m_rsc:insert(Rsc, UpdateOptions, Context);
        false when not IsImportDeleted ->
            case m_rsc_gone:is_gone_uri(Uri, Context) of
                true ->
                    {error, deleted};
                false ->
                    m_rsc:insert(RscLang, UpdateOptions, Context)
            end;
        {true, LocalId} ->
            case not is_imported(LocalId, Context)
                or not m_rsc:p_no_acl(LocalId, is_authoritative, Context)
            of
                true ->
                    m_rsc:update(LocalId, RscLang, UpdateOptions, Context);
                false ->
                    {error, authoritative}
            end
    end;
update_rsc_1(LocalId, _RemoteRId, Rsc, _ImportedAcc, _Options, Context) when is_integer(LocalId) ->
    RscLang = ensure_language_prop(Rsc),
    UpdateOptions = [
        {is_escape_texts, false},
        is_import
    ],
    m_rsc:update(LocalId, RscLang, UpdateOptions, Context).


ensure_language_prop(#{ <<"language">> := _ } = Rsc) ->
    Rsc;
ensure_language_prop(Rsc) ->
    Langs = maps:fold(
        fun
            (_K, #trans{ tr = Tr }, Acc) ->
                Langs = [ Iso || {Iso, _} <- Tr ],
                Acc ++ Langs;
            (_, _, Acc) ->
                Acc
        end,
        [],
        Rsc),
    case Langs of
        [_|_] -> Rsc#{ <<"language">> => lists:usort(Langs) };
        [] -> Rsc
    end.


cleanup_map_ids(RemoteRId, Rsc, UriTemplate, ImportedAcc, Options, Context) ->
    PropsForced = proplists:get_value(props_forced, Options, #{}),
    PropsDefault = proplists:get_value(props_default, Options, #{}),

    IsAuthCopy = proplists:get_value(is_authoritative, Options, false),

    % Import options for resources that are directly referred.
    % For example menus and the 'rsc_id' in blocks.
    N = proplists:get_value(import_edges, Options, 0),
    ReferredOptions = [
        {import_edges, erlang:max(0, N - 1)},
        {props_forced, maps:remove(<<"category_id">>, PropsForced)}
        | proplists:delete(import_edges,
            proplists:delete(props_forced, Options))
    ],

    % Remove or map modifier_id, creator_id, etc
    {Rsc1, ImportedAcc1} = maps:fold(
        fun
            (K, V, {Acc, ImpAcc}) ->
                case maps:is_key(K, PropsForced) of
                    true ->
                        % Forced props are assumed to use local ids, no mapping needed.
                        {Acc, ImpAcc};
                    false ->
                        case K of
                            % If we make an authoritative copy then the creator/modifier is the current user
                            <<"creator_id">> when IsAuthCopy ->
                                {Acc#{ <<"creator_id">> => z_acl:user(Context) }, ImpAcc};
                            <<"modifier_id">> when IsAuthCopy ->
                                {Acc#{ <<"modifier_id">> => z_acl:user(Context) }, ImpAcc};

                            % Be specific about the category id - handled by caller
                            <<"category_id">> -> {Acc, ImpAcc};
                            <<"category">> -> {Acc, ImpAcc};

                            % Importing into a content-group depends on the import Options
                            <<"content_group_id">> ->
                                case find_content_group(V, Context) of
                                    {ok, CGId} ->
                                        {Acc#{ <<"content_group_id">> => CGId }, ImpAcc};
                                    {error, _} ->
                                        {Acc, ImpAcc}
                                end;
                            <<"content_group">> -> {Acc, ImpAcc};

                            % Other ids are mapped to placeholders or local ids
                            <<"menu">> when is_list(V) ->
                                % map ids in menu to local ids
                                {Menu1, ImpAcc1} = map_menu(V, UriTemplate, [], ImpAcc, ReferredOptions, Context),
                                {Acc#{ <<"menu">> => Menu1 }, ImpAcc1};

                            % All other values are mapped, considering the key and value to see if
                            % it is a resource reference.
                            _ ->
                                {V1, ImpAcc1} = map_key_value(K, V, UriTemplate, ImpAcc, ReferredOptions, Context),
                                {Acc#{ K => V1 }, ImpAcc1}
                        end
                end
        end,
        {#{}, ImportedAcc},
        Rsc),

    % Set forced and default props
    Rsc2 = maps:merge(Rsc1, PropsForced),
    Rsc3 = maps:merge(PropsDefault, Rsc2),
    Rsc4 = case maps:find(<<"is_authoritative">>, PropsForced) of
        {ok, true} ->
            Rsc3#{
                <<"is_authoritative">> => true,
                <<"uri">> => undefined
            };
        _ when IsAuthCopy ->
            Rsc3#{
                <<"is_authoritative">> => true,
                <<"uri">> => undefined
            };
        _ ->
            Rsc3#{
                <<"uri">> => maps:get(<<"uri">>, RemoteRId),
                <<"is_authoritative">> => false
            }
    end,

    % Ensure that the is_published flag is present, defaults to true
    Rsc5 = case maps:find(<<"is_published">>, Rsc4) of
        {ok, true} -> Rsc4;
        _ -> Rsc4#{ <<"is_published">> => true }
    end,
    {Rsc5, ImportedAcc1}.

%% @doc Check if the value looks like an exported id description.
is_id_map(#{
        <<"id">> := Id,
        <<"uri">> := Uri,
        <<"is_a">> := [ Cat |_ ]
    }) when is_integer(Id), is_binary(Uri), is_binary(Cat) ->
    true;
is_id_map(_) ->
    false.

%% @doc Map all ids in a menu to local (stub) resources. Skip all tree entries
%% that could not be mapped to local ids.
map_menu([ #rsc_tree{ id = RemoteId, tree = Sub } | Rest ], UriTemplate, Acc, ImpAcc, Options, Context) ->
    case map_id(RemoteId, UriTemplate, ImpAcc, Options, Context) of
        {ok, {LocalId, ImpAcc1}} ->
            {Sub1, ImpAcc2} = map_menu(Sub, UriTemplate, [], ImpAcc1, Options, Context),
            Acc1 = [ #rsc_tree{ id = LocalId, tree = Sub1 } | Acc ],
            map_menu(Rest, UriTemplate, Acc1, ImpAcc2, Options, Context);
        {error, _} ->
            % Skip the tree entry if the remote id could not be mapped
            map_menu(Rest, UriTemplate, Acc, ImpAcc, Options, Context)
    end;
map_menu([ _ | Rest ], UriTemplate, Acc, ImpAcc, Options, Context) ->
    map_menu(Rest, UriTemplate, Acc, ImpAcc, Options, Context);
map_menu([], _UriTemplate, Acc, ImpAcc, _Options, _Context) ->
    {lists:reverse(Acc), ImpAcc}.


%% @doc Map all ids in a list to local (stub) resources.
map_list([{K, V} | Rest], UriTemplate, Acc, ImpAcc, Options, Context) when is_binary(K) ->
    {V1, ImpAcc1} = map_key_value(K, V, UriTemplate, ImpAcc, Options, Context),
    map_list(Rest, UriTemplate, [{K, V1} | Acc], ImpAcc1, Options, Context);
map_list([V | Rest], UriTemplate, Acc, ImpAcc, Options, Context) ->
    {V1, ImpAcc1} = map_value(V, UriTemplate, ImpAcc, Options, Context),
    map_list(Rest, UriTemplate, [V1 | Acc], ImpAcc1, Options, Context);
map_list([], _UriTemplate, Acc, ImpAcc, _Options, _Context) ->
    {lists:reverse(Acc), ImpAcc}.

%% @doc Map all ids in a map. The map itself could be an id reference, if so then
%% replace the map with the newly referred local id.
map_map(Map, UriTemplate, ImpAcc, Options, Context) when is_map(Map) ->
    case is_id_map(Map) of
        true ->
            case map_id(Map, UriTemplate, ImpAcc, Options, Context) of
                {ok, {LocalId, ImpAcc1}} ->
                    {LocalId, ImpAcc1};
                {error, _} ->
                    {Map, ImpAcc}
            end;
        false ->
            maps:fold(
                fun(K, V, {BAcc, BImpAcc}) ->
                    {V1, BImpAcc1} = map_key_value(K, V, UriTemplate, BImpAcc, Options, Context),
                    {BAcc#{ K => V1 }, BImpAcc1}
                end,
                {#{}, ImpAcc},
                Map)
    end.

%% @doc Map ids in the value, irrespective of the name of the key.
map_value(V, UriTemplate, ImpAcc, Options, Context) ->
    map_key_value(<<>>, V, UriTemplate, ImpAcc, Options, Context).

%% @doc Map ids in the value, knowing the name and the semantics of the key.
map_key_value(K, V, UriTemplate, ImpAcc, Options, Context) ->
    case m_rsc_export:is_id_prop(K) orelse is_id_map(V) of
        true ->
            case map_id(V, UriTemplate, ImpAcc, Options, Context) of
                {ok, {LocalId, ImpAcc1}} ->
                    ImpAcc2 = ImpAcc1#{
                        uri(V) => LocalId
                    },
                    {LocalId, ImpAcc2};
                {error, _} ->
                    {undefined, ImpAcc}
            end;
        false when is_list(V) ->
            map_list(V, UriTemplate, [], ImpAcc, Options, Context);
        false when is_map(V) ->
            map_map(V, UriTemplate, ImpAcc, Options, Context);
        false ->
            map_html(K, V, UriTemplate, ImpAcc, Options, Context)
    end.


%% @doc Map a remote id to a local id, optionally creating a new (stub) resource.
map_id(RemoteId, UriTemplate, ImpAcc, Options, Context) when is_integer(RemoteId) ->
    case is_uri(UriTemplate) of
        true ->
            Uri = binary:replace(UriTemplate, <<":id">>, z_convert:to_binary(RemoteId)),
            Rsc = #{
                <<"uri">> => Uri
            },
            maybe_create_empty(Rsc, ImpAcc, Options, Context);
        false ->
            {error, enoent}
    end;
map_id(Remote, _UriTemplate, ImpAcc, Options, Context) when is_map(Remote) ->
    maybe_create_empty(Remote, ImpAcc, Options, Context);
map_id(Remote, _UriTemplate, ImpAcc, Options, Context) when is_binary(Remote) ->
    case is_uri(Remote) of
        true ->
            Rsc = #{
                <<"uri">> => Remote
            },
            maybe_create_empty(Rsc, ImpAcc, Options, Context);
        false ->
            {error, enoent}
    end;
map_id(_, _, _ImpAcc, _Options, _Context) ->
    {error, enoent}.


%% @doc Map texts in html properties.
map_html(_Key, #trans{} = Value, UriTemplate, ImportAcc, Options, Context) ->
    map_html_1(Value, UriTemplate, ImportAcc, Options, Context);
map_html(Key, Value, UriTemplate, ImportAcc, Options, Context) ->
    case is_html_prop(Key) of
        true ->
            map_html_1(Value, UriTemplate, ImportAcc, Options, Context);
        false ->
            {Value, ImportAcc}
    end.

map_html_1(#trans{ tr = Tr }, UriTemplate, ImportAcc, Options, Context) ->
    {Tr1, AccIds1} = lists:foldr(
        fun({Lang, Text}, {TAcc, TAccIds}) ->
            {Text1, TAccIds2} = map_html_1(Text, UriTemplate, TAccIds, Options, Context),
            {[ {Lang, Text1} | TAcc ], TAccIds2}
        end,
        {[], ImportAcc},
        Tr),
    {#trans{ tr = Tr1 }, AccIds1};
map_html_1(Text, UriTemplate, ImportAcc, Options, Context) when is_binary(Text) ->
    % Import options for resources that are embedded.
    % For example images and links in HTML texts.
    EmbeddedOptions = proplists:delete(import_edges, Options),
    case filter_embedded_media:embedded_media(Text, Context) of
        [] ->
            {Text, ImportAcc};
        EmbeddedIds ->
            {Text1, ImportAcc1} = lists:foldl(
                fun(RemoteId, {TextAcc, ImpAcc}) ->
                    case map_id(RemoteId, UriTemplate, ImpAcc, EmbeddedOptions, Context) of
                        {ok, {LocalId, ImpAcc1}} ->
                            From = <<"<!-- z-media ", (integer_to_binary(RemoteId))/binary, " ">>,
                            To = <<"<!-- z-media-local ", (integer_to_binary(LocalId))/binary, " ">>,
                            {binary:replace(TextAcc, From, To, [ global ]), ImpAcc1};
                        {error, _} ->
                            From = <<"<!-- z-media ", (integer_to_binary(RemoteId))/binary, " ">>,
                            To = <<"<!-- z-media-temp 0 ">>,
                            {binary:replace(TextAcc, From, To, [ global ]), ImpAcc}
                    end
                end,
                {Text, ImportAcc},
                EmbeddedIds),
            Text2 = binary:replace(Text1, <<"<!-- z-media-local ">>, <<"<!-- z-media ">>, [ global ]),
            {Text2, ImportAcc1}
    end.

is_uri(undefined) -> false;
is_uri(<<"http:", _/binary>>) -> true;
is_uri(<<"https:", _/binary>>) -> true;
is_uri(<<C, _/binary>> = Uri) when C >= $a, C =< $z -> is_schemed(Uri);
is_uri(<<C, _/binary>> = Uri) when C >= $A, C =< $Z -> is_schemed(Uri);
is_uri(_) -> false.

is_schemed(<<C, R/binary>>) when C >= $a, C =< $z -> is_schemed(R);
is_schemed(<<C, R/binary>>) when C >= $A, C =< $Z -> is_schemed(R);
is_schemed(<<$:, _/binary>>) -> true;
is_schemed(<<>>) -> false;
is_schemed(_) -> false.


is_html_prop(<<"body">>) -> true;
is_html_prop(<<"body_", _/binary>>) -> true;
is_html_prop(K) ->
    binary:longest_common_suffix([ K, <<"_html">> ]) =:= 5.


maybe_import_medium(LocalId, #{ <<"medium">> := Medium, <<"medium_url">> := MediaUrl }, Options, Context)
    when is_binary(MediaUrl), MediaUrl =/= <<>>, is_map(Medium) ->
    % If medium is outdated (compare with created date in medium record)
    %    - download URL
    %    - save into medium, ensure medium created date has been set (aka copied)
    % TODO: add medium created date option (to set equal to imported medium)
    Created = maps:get(<<"created">>, Medium, calendar:universal_time()),
    RemoteMedium = #{
        <<"created">> => Created
    },
    LocalMedium = m_media:get(LocalId, Context),
    case is_newer_medium(RemoteMedium, LocalMedium) of
        true ->
            MediaOptions = [
                {is_escape_texts, false},
                is_import,
                no_touch,
                {fetch_options, proplists:get_value(fetch_options, Options, [])}
            ],
            RscProps = #{
                <<"original_filename">> => maps:get(<<"original_filename">>, Medium, undefined)
            },
            _ = m_media:replace_url(MediaUrl, LocalId, RscProps, MediaOptions, Context);
        false ->
            ok
    end,
    {ok, LocalId};
maybe_import_medium(LocalId, #{ <<"medium">> := Medium }, _Options, Context)
    when is_map(Medium) ->
    % Overwrite local medium record with the imported medium record
    % [ sanitize any HTML in the medium record ]
    case z_notifier:first(#media_import_medium{ id = LocalId, medium = Medium }, Context) of
        undefined ->
            ?LOG_NOTICE(#{
                text => <<"Resource import dropped medium record">>,
                in => zotonic_core,
                rsc_id => LocalId
            }),
            {ok, LocalId};
        ok ->
            {ok, LocalId};
        {ok, _} ->
            {ok, LocalId};
        {error, _} = Error ->
            Error
    end;
maybe_import_medium(LocalId, #{}, _Options, Context) ->
    % If no medium:
    %    Delete local medium record (if any)
    case m_media:get(LocalId, Context) of
        undefined ->
            {ok, LocalId};
        _LocalMedium ->
            _ = m_media:delete(LocalId, Context),
            {ok, LocalId}
    end.

is_newer_medium(#{ <<"created">> := Remote }, #{ <<"created">> := Local }) when Local < Remote ->
    true;
is_newer_medium(#{ <<"created">> := _ }, undefined) ->
    true;
is_newer_medium(_, _) ->
    false.


% <<"edges">> := #{
%   <<"depiction">> => #{
%       <<"predicate">> => #{
%             <<"id">> => 304,
%             <<"is_a">> => [ meta, predicate ],
%             <<"name">> => <<"depiction">>,
%             <<"title">> => {trans,[{en,<<"Depiction">>}]},
%             <<"uri">> => <<"http://xmlns.com/foaf/0.1/depiction">>
%       },
%       <<"objects">> => [
%             #{
%                 <<"created">> => {{2020,12,23},{15,4,55}},
%                 <<"object_id">> => #{
%                      <<"id">> => 28992,
%                      <<"is_a">> => [media,image],
%                      <<"name">> => undefined,
%                      <<"title">> =>
%                         {trans,[{nl,<<"NL: a.jpg">>},{en,<<"a.jpg">>}]},
%                      <<"uri">> =>
%                          <<"https://learningstone.test:8443/id/28992">>
%                 },
%                 <<"seq">> => 1
%             },
%             ... more objects
%       ]
%   },
%   ... more predicates
% }

%% @doc Import all edges, return a list of newly created objects
import_edges(LocalId, #{ <<"edges">> := Edges }, ImportedAcc, Options, Context) when is_map(Edges) ->
    % Delete all edges with predicates not mentioned in the import
    LocalPredicates = [ z_convert:to_binary(P) || P <- m_edge:object_predicates(LocalId, Context) ],
    ImportPredicates = maps:keys(Edges),

    lists:foreach(
        fun
            (<<"hasusergroup">>) ->
                % Local ACL predicate - keep as is
                ok;
            (P) ->
                % Delete all edges for predicate P
                m_edge:set_sequence(LocalId, P, [], Context)
        end,
        LocalPredicates -- ImportPredicates),

    % Sync predicates present in the import data.
    maps:fold(
        fun
            (Name, #{ <<"predicate">> := Pred, <<"objects">> := Os }, Acc) ->
                case find_allowed_predicate(Name, Pred, Options, Context) of
                    {ok, PredId} ->
                        replace_edges(LocalId, PredId, Os, Acc, Options, Context);
                    {error, _} ->
                        Acc
                end;
            (Name, V, Acc) ->
                ?LOG_WARNING(#{
                    text => <<"Import of unknown predicate">>,
                    in => zotonic_core,
                    name => Name,
                    props => V
                }),
                Acc
        end,
        ImportedAcc,
        Edges).

replace_edges(LocalId, PredId, Os, ImportedAcc, Options, Context) ->
    % Keep order of edges
    {ObjectIds, ImportedAcc1} = lists:foldr(
        fun(Edge, {Acc, ImpAcc}) ->
            Object = maps:get(<<"object_id">>, Edge),
            case is_imported_resource(Object, ImpAcc, Options, Context) of
                false ->
                    case maybe_create_empty(Object, ImpAcc, Options, Context) of
                        {ok, {ObjectId, ImpAcc1}} ->
                            {[ ObjectId | Acc ], ImpAcc1};
                        {error, Reason} ->
                            ?LOG_DEBUG(#{
                                text => <<"Skipping import of object">>,
                                in => zotonic_core,
                                result => error,
                                reason => Reason,
                                object => Object
                            }),
                            {Acc, ImpAcc}
                    end;
                {true, ObjectId} ->
                    ImpAcc1 = ImpAcc#{
                        uri(Object) => ObjectId
                    },
                    {[ ObjectId | Acc ], ImpAcc1}
            end
        end,
        {[], ImportedAcc},
        Os),
    m_edge:set_sequence(LocalId, PredId, ObjectIds, Context),
    ImportedAcc1.


uri(Uri) when is_binary(Uri) -> Uri;
uri(#{ <<"uri">> := Uri }) -> Uri.


%% @doc Find the predicate to be imported, check against the allow/deny lists
%% of predicates.
find_allowed_predicate(Name, Pred, Options, Context) ->
    case find_predicate(Name, Pred, Context) of
        {ok, PredId} ->
            PredName = m_rsc:p_no_acl(PredId, name, Context),
            Allow = proplists:get_value(allow_predicate, Options),
            Deny = proplists:get_value(deny_predicate, Options, [ <<"hasusergroup">> ]),
            case (Allow =:= undefined orelse lists:member(PredName, Allow))
                andalso not lists:member(PredName, Deny)
            of
                true ->
                    {ok, PredId};
                false ->
                    ?LOG_NOTICE(#{
                        text => <<"Not importing edges because predicate is not allowed.">>,
                        in => zotonic_core,
                        result => error,
                        reason => eacces,
                        predicate => PredName
                    }),
                    {error, eacces}
            end;
        {error, _} = Error ->
            Error
    end.

%% @doc Find the local predicate to be imported.
%% For now we only import if we know the predicate.
%% An option could be added to insert the predicate if it is was unknown.
find_predicate(Name, Pred, Context) ->
    PredId = case m_rsc:rid(Name, Context) of
        undefined -> m_rsc:rid(Pred, Context);
        Id -> Id
    end,
    case m_rsc:is_a(PredId, predicate, Context) of
        true ->
            {ok, PredId};
        false ->
            {error, enoent}
    end.


%% @doc Find a content group, prefer matching on name before URI.
find_content_group(undefined, _Context) ->
    {error, enoent};
find_content_group(RId, Context) ->
    Name = maps:get(<<"name">>, RId, undefined),
    CGId = case m_rsc:rid(Name, Context) of
        undefined -> m_rsc:rid(RId, Context);
        Id -> Id
    end,
    case m_rsc:is_a(CGId, content_group, Context) of
        true ->
            {ok, CGId};
        false ->
            {error, enoent}
    end.

%% @doc Find the category to be imported, check against the allow/deny lists
%% of categories. Per default importing resources into the 'meta' category
%% is not allowed.
-spec find_allowed_category( RId::map(),  Rsc::map(), options(), z:context() ) -> {ok, m_rsc:resource_id()} | {error, term()}.
find_allowed_category(RId, Rsc, Options, Context) ->
    CatId = find_category(RId, Rsc, Context),
    CatName = m_rsc:p_no_acl(CatId, name, Context),
    Allow = proplists:get_value(allow_category, Options, undefined),
    Deny = proplists:get_value(deny_category, Options, [ <<"meta">> ]),
    case
        (Allow =:= undefined orelse matching_category(CatName, Allow, Context))
        andalso not matching_category(CatName, Deny, Context)
    of
        true ->
            {ok, CatId};
        false ->
            ?LOG_NOTICE(#{
                text => <<"Not importing resource because category is disallowed">>,
                in => zotonic_core,
                result => error,
                reason => eacces,
                category => CatName,
                category_id => CatId,
                rsc_id => RId
            }),
            {error, eacces}
    end.

matching_category(Name, Cats, Context) ->
    lists:any(
        fun(Cat) ->
            m_category:is_a(Name, Cat, Context)
        end,
        Cats).


%% @doc Find the category to be imported. This tries to map the 'is_a'
%% and the uri of the category.
-spec find_category( RId::map(),  Rsc::map(), z:context() ) -> m_rsc:resource_id().
find_category(RId, Rsc, Context) ->
    RscCatId = case maps:get(<<"category_id">>, Rsc, undefined) of
        #{ <<"name">> := Name, <<"uri">> := CatUri } ->
            case m_rsc:rid(CatUri, Context) of
                undefined -> m_rsc:rid(Name, Context);
                UriId -> UriId
            end;
        #{ <<"uri">> := CatUri } ->
            m_rsc:rid(CatUri, Context);
        _ ->
            undefined
    end,
    case m_rsc:is_a(RscCatId, category, Context) of
        true ->
            RscCatId;
        false ->
            case maps:get(<<"is_a">>, RId, undefined) of
                IsA when is_list(IsA) ->
                    case first_category(lists:reverse(IsA), Context) of
                        {ok, IsACatId} ->
                            IsACatId;
                        error ->
                            m_rsc:rid(other, Context)
                    end;
                _ ->
                    m_rsc:rid(other, Context)
            end
    end.


first_category([], _Context) ->
    error;
first_category([ R | Rs ], Context) ->
    case m_category:name_to_id(R, Context) of
        {ok, CatId} ->
            {ok, CatId};
        {error, _} ->
            first_category(Rs, Context)
    end.

%% @doc Return the host part of an URI
-spec host( binary() | string() ) -> binary().
host(Uri) ->
    case uri_string:parse(Uri) of
        #{ host := Host } ->
            z_convert:to_binary(Host);
        #{ scheme := Scheme } when Scheme =/= <<"http">>, Scheme =/= <<"https">> ->
            Scheme
    end.


%% @doc Check if the site is the local site
is_local_site(Uri, Context ) ->
    Site = z_context:site(Context),
    case z_sites_dispatcher:get_site_for_url(Uri) of
        {ok, Site} -> true;
        {ok, _} -> false;
        undefined -> false
    end.

%% @doc Generate a new page path by appending a number.
-spec unique_page_path(binary(), z:context()) -> binary() | undefined.
unique_page_path(Path, Context) ->
    unique_page_path(Path, 1, Context).

unique_page_path(Path, N, Context) ->
    B = integer_to_binary(N),
    Path1 = <<Path/binary, $-, B/binary>>,
    case m_rsc:page_path_to_id(Path1, Context) of
        {ok, _} ->
            unique_page_path(Path, N+1, Context);
        {redirect, _} ->
            Path1;
        {error, {unknown_page_path, _}} ->
            Path1;
        {error, _} ->
            undefined
    end.

%% @doc Generate a new name by appending a number.
-spec unique_name(binary(), z:context()) -> binary() | undefined.
unique_name(Name, Context) ->
    unique_name(Name, 1, Context).

unique_name(Name, N, Context) ->
    B = integer_to_binary(N),
    Name1 = <<Name/binary, $_, B/binary>>,
    case m_rsc:name_to_id(Name1, Context) of
        {ok, _} ->
            unique_page_path(Name, N+1, Context);
        {error, _} ->
            Name1
    end.

% Install the datamodel for managing resource imports, especially the rights and options for the imports.
-spec install( z:context() ) -> ok.
install(Context) ->
    case z_db:table_exists(rsc_import, Context) of
        true ->
            ok;
        false ->
            [] = z_db:q("
                create table rsc_import (
                    id int not null,
                    user_id int,
                    host character varying(128) not null,
                    uri character varying(2048) not null,
                    props bytea,
                    created timestamp with time zone not null default now(),
                    next_import_check timestamp with time zone,
                    last_import_check timestamp with time zone,
                    last_import_date timestamp with time zone,
                    last_import_status character varying(20) not null default '',

                    constraint rsc_import_pkey primary key (id),
                    constraint fk_rsc_import_id foreign key (id)
                        references rsc(id)
                        on delete cascade on update cascade,
                    constraint fk_rsc_import_user_id foreign key (user_id)
                        references rsc(id)
                        on delete set null on update cascade
                )",
                Context),
            Indices = [
                {"fki_rsc_import_user_id", "user_id"},
                {"import_rsc_uri", "uri"},
                {"import_rsc_host", "host"},
                {"import_rsc_created_key", "created"},
                {"import_rsc_last_import_check_key", "last_import_check"},
                {"import_rsc_next_import_check_key", "next_import_check"}
            ],
            [ z_db:q("create index "++Name++" on rsc_import ("++Cols++")", Context) || {Name, Cols} <- Indices ],
            z_db:flush(Context)
    end.