% 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(couch_mrview_purge_docs_tests).

-include_lib("couch/include/couch_eunit.hrl").
-include_lib("couch/include/couch_db.hrl").
-include_lib("couch_mrview/include/couch_mrview.hrl").

-define(TIMEOUT, 1000).

setup() ->
    meck:new(couch_index_updater, [passthrough]),
    {ok, Db} = couch_mrview_test_util:init_db(?tempdb(), map, 5),
    Db.

teardown(Db) ->
    couch_db:close(Db),
    couch_server:delete(couch_db:name(Db), [?ADMIN_CTX]),
    meck:unload(),
    ok.

view_purge_test_() ->
    {
        "Map views",
        {
            setup,
            fun test_util:start_couch/0,
            fun test_util:stop_couch/1,
            {
                foreach,
                fun setup/0,
                fun teardown/1,
                [
                    fun test_purge_single/1,
                    fun test_purge_partial/1,
                    fun test_purge_complete/1,
                    fun test_purge_nochange/1,
                    fun test_purge_index_reset/1,
                    fun test_purge_compact_size_check/1,
                    fun test_purge_single_for_docid_with_list/1,
                    fun test_purge_complete_for_docid_with_list/1,
                    fun test_purge_compact_for_stale_purge_cp_without_client/1,
                    fun test_purge_compact_for_stale_purge_cp_with_client/1
                ]
            }
        }
    }.

test_purge_single(Db) ->
    ?_test(begin
        Result = run_query(Db, []),
        Expect =
            {ok, [
                {meta, [{total, 5}, {offset, 0}]},
                {row, [{id, <<"1">>}, {key, 1}, {value, 1}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect, Result),

        FDI = couch_db:get_full_doc_info(Db, <<"1">>),
        Rev = get_rev(FDI),
        {ok, [{ok, _PRevs}]} = couch_db:purge_docs(
            Db,
            [{<<"UUID1">>, <<"1">>, [Rev]}]
        ),
        {ok, Db2} = couch_db:reopen(Db),

        Result2 = run_query(Db2, []),
        Expect2 =
            {ok, [
                {meta, [{total, 4}, {offset, 0}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect2, Result2)
    end).

test_purge_single_for_docid_with_list(Db) ->
    ?_test(begin
        Result = run_query(Db, []),
        Expect =
            {ok, [
                {meta, [{total, 5}, {offset, 0}]},
                {row, [{id, <<"1">>}, {key, 1}, {value, 1}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect, Result),

        FDI = couch_db:get_full_doc_info(Db, <<"1">>),
        Rev = get_rev(FDI),
        {ok, [{ok, _PRevs}]} = couch_db:purge_docs(
            Db,
            [{<<"UUID1">>, "1", [Rev]}]
        ),
        {ok, Db2} = couch_db:reopen(Db),

        Result2 = run_query(Db2, []),
        Expect2 =
            {ok, [
                {meta, [{total, 4}, {offset, 0}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect2, Result2)
    end).

test_purge_partial(Db) ->
    ?_test(begin
        Result = run_query(Db, []),
        Expect =
            {ok, [
                {meta, [{total, 5}, {offset, 0}]},
                {row, [{id, <<"1">>}, {key, 1}, {value, 1}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect, Result),

        FDI1 = couch_db:get_full_doc_info(Db, <<"1">>),
        Rev1 = get_rev(FDI1),
        Update =
            {[
                {'_id', <<"1">>},
                {'_rev', couch_doc:rev_to_str({1, [couch_hash:md5_hash(<<"1.2">>)]})},
                {'val', 1.2}
            ]},
        {ok, [_Rev2]} = save_docs(Db, [Update], [?REPLICATED_CHANGES]),

        PurgeInfos = [{<<"UUID1">>, <<"1">>, [Rev1]}],

        {ok, _} = couch_db:purge_docs(Db, PurgeInfos),
        {ok, Db2} = couch_db:reopen(Db),

        Result2 = run_query(Db2, []),
        Expect2 =
            {ok, [
                {meta, [{total, 5}, {offset, 0}]},
                {row, [{id, <<"1">>}, {key, 1.2}, {value, 1.2}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect2, Result2)
    end).

test_purge_complete(Db) ->
    ?_test(begin
        Result = run_query(Db, []),
        Expect =
            {ok, [
                {meta, [{total, 5}, {offset, 0}]},
                {row, [{id, <<"1">>}, {key, 1}, {value, 1}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect, Result),

        FDI1 = couch_db:get_full_doc_info(Db, <<"1">>),
        Rev1 = get_rev(FDI1),
        FDI2 = couch_db:get_full_doc_info(Db, <<"2">>),
        Rev2 = get_rev(FDI2),
        FDI5 = couch_db:get_full_doc_info(Db, <<"5">>),
        Rev5 = get_rev(FDI5),

        PurgeInfos = [
            {<<"UUID1">>, <<"1">>, [Rev1]},
            {<<"UUID2">>, <<"2">>, [Rev2]},
            {<<"UUID5">>, <<"5">>, [Rev5]}
        ],
        {ok, _} = couch_db:purge_docs(Db, PurgeInfos),
        {ok, Db2} = couch_db:reopen(Db),

        Result2 = run_query(Db2, []),
        Expect2 =
            {ok, [
                {meta, [{total, 2}, {offset, 0}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]}
            ]},
        ?assertEqual(Expect2, Result2)
    end).

test_purge_complete_for_docid_with_list(Db) ->
    ?_test(begin
        Result = run_query(Db, []),
        Expect =
            {ok, [
                {meta, [{total, 5}, {offset, 0}]},
                {row, [{id, <<"1">>}, {key, 1}, {value, 1}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect, Result),

        FDI1 = couch_db:get_full_doc_info(Db, <<"1">>),
        Rev1 = get_rev(FDI1),
        FDI2 = couch_db:get_full_doc_info(Db, <<"2">>),
        Rev2 = get_rev(FDI2),
        FDI5 = couch_db:get_full_doc_info(Db, <<"5">>),
        Rev5 = get_rev(FDI5),

        PurgeInfos = [
            {<<"UUID1">>, "1", [Rev1]},
            {<<"UUID2">>, "2", [Rev2]},
            {<<"UUID5">>, "5", [Rev5]}
        ],
        {ok, _} = couch_db:purge_docs(Db, PurgeInfos),
        {ok, Db2} = couch_db:reopen(Db),

        Result2 = run_query(Db2, []),
        Expect2 =
            {ok, [
                {meta, [{total, 2}, {offset, 0}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]}
            ]},
        ?assertEqual(Expect2, Result2)
    end).

test_purge_nochange(Db) ->
    ?_test(begin
        Result = run_query(Db, []),
        Expect =
            {ok, [
                {meta, [{total, 5}, {offset, 0}]},
                {row, [{id, <<"1">>}, {key, 1}, {value, 1}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect, Result),

        FDI1 = couch_db:get_full_doc_info(Db, <<"1">>),
        Rev1 = get_rev(FDI1),

        PurgeInfos = [
            {<<"UUID1">>, <<"6">>, [Rev1]}
        ],
        {ok, _} = couch_db:purge_docs(Db, PurgeInfos),
        {ok, Db2} = couch_db:reopen(Db),

        Result2 = run_query(Db2, []),
        Expect2 =
            {ok, [
                {meta, [{total, 5}, {offset, 0}]},
                {row, [{id, <<"1">>}, {key, 1}, {value, 1}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect2, Result2)
    end).

test_purge_index_reset(Db) ->
    ?_test(begin
        ok = couch_db:set_purge_infos_limit(Db, 2),
        {ok, Db1} = couch_db:reopen(Db),

        Result = run_query(Db1, []),
        Expect =
            {ok, [
                {meta, [{total, 5}, {offset, 0}]},
                {row, [{id, <<"1">>}, {key, 1}, {value, 1}]},
                {row, [{id, <<"2">>}, {key, 2}, {value, 2}]},
                {row, [{id, <<"3">>}, {key, 3}, {value, 3}]},
                {row, [{id, <<"4">>}, {key, 4}, {value, 4}]},
                {row, [{id, <<"5">>}, {key, 5}, {value, 5}]}
            ]},
        ?assertEqual(Expect, Result),

        PurgeInfos = lists:map(
            fun(I) ->
                DocId = integer_to_binary(I),
                FDI = couch_db:get_full_doc_info(Db, DocId),
                Rev = get_rev(FDI),
                {couch_uuids:random(), DocId, [Rev]}
            end,
            lists:seq(1, 5)
        ),
        {ok, _} = couch_db:purge_docs(Db1, PurgeInfos),

        {ok, Db2} = couch_db:reopen(Db1),

        % Forcibly set the purge doc to a newer purge
        % sequence to force an index reset. This should
        % never happen in real life but the reset
        % is required for correctness.
        {ok, #doc{body = {OldProps}} = LocalDoc} = get_local_purge_doc(Db2),
        NewPurgeSeq = {<<"purge_seq">>, 5},
        NewProps = lists:keyreplace(<<"purge_seq">>, 1, OldProps, NewPurgeSeq),
        RewindDoc = LocalDoc#doc{body = {NewProps}},
        {ok, _} = couch_db:update_doc(Db2, RewindDoc, []),

        % Compact the database to remove purge infos
        {ok, _} = couch_db:start_compact(Db2),
        wait_compaction(couch_db:name(Db), "database", ?LINE),

        {ok, Db3} = couch_db:reopen(Db2),
        Result2 = run_query(Db3, []),
        Expect2 =
            {ok, [
                {meta, [{total, 0}, {offset, 0}]}
            ]},
        ?assertEqual(Expect2, Result2),

        % Assert that we had a reset
        meck:wait(
            1,
            couch_index_updater,
            handle_info,
            [{'EXIT', '_', {reset, '_'}}, '_'],
            5000
        )
    end).

test_purge_compact_size_check(Db) ->
    ?_test(begin
        DbName = couch_db:name(Db),
        Docs = couch_mrview_test_util:make_docs(normal, 6, 200),
        {ok, Db1} = couch_mrview_test_util:save_docs(Db, Docs),
        _Result = run_query(Db1, []),
        DiskSizeBefore = db_disk_size(DbName),

        PurgedDocsNum = 150,
        IdsRevs = lists:foldl(
            fun(Id, CIdRevs) ->
                Id1 = docid(Id),
                FDI1 = couch_db:get_full_doc_info(Db1, Id1),
                Rev1 = get_rev(FDI1),
                UUID1 = uuid(Id),
                [{UUID1, Id1, [Rev1]} | CIdRevs]
            end,
            [],
            lists:seq(1, PurgedDocsNum)
        ),
        {ok, _} = couch_db:purge_docs(Db1, IdsRevs),

        {ok, Db2} = couch_db:reopen(Db1),
        _Result1 = run_query(Db2, []),
        {ok, PurgedIdRevs} = couch_db:fold_purge_infos(
            Db2,
            0,
            fun fold_fun/2,
            [],
            []
        ),
        ?assertEqual(PurgedDocsNum, length(PurgedIdRevs)),
        config:set("couchdb", "file_compression", "snappy", false),

        {ok, Db3} = couch_db:open_int(DbName, []),
        {ok, _CompactPid} = couch_db:start_compact(Db3),
        wait_compaction(DbName, "database", ?LINE),
        ok = couch_db:close(Db3),
        DiskSizeAfter = db_disk_size(DbName),
        ?assert(DiskSizeBefore > DiskSizeAfter)
    end).

test_purge_compact_for_stale_purge_cp_without_client(Db) ->
    ?_test(begin
        DbName = couch_db:name(Db),
        % add more documents to database for purge
        Docs = couch_mrview_test_util:make_docs(normal, 6, 200),
        {ok, Db1} = couch_mrview_test_util:save_docs(Db, Docs),

        % change PurgedDocsLimit to 10 from 1000 to
        % avoid timeout of eunit test
        PurgedDocsLimit = 10,
        couch_db:set_purge_infos_limit(Db1, PurgedDocsLimit),

        % purge 150 documents
        PurgedDocsNum = 150,
        PurgeInfos = lists:foldl(
            fun(Id, CIdRevs) ->
                Id1 = docid(Id),
                FDI1 = couch_db:get_full_doc_info(Db1, Id1),
                Rev1 = get_rev(FDI1),
                UUID1 = uuid(Id),
                [{UUID1, Id1, [Rev1]} | CIdRevs]
            end,
            [],
            lists:seq(1, PurgedDocsNum)
        ),
        {ok, _} = couch_db:purge_docs(Db1, PurgeInfos),

        {ok, Db2} = couch_db:reopen(Db1),
        {ok, PurgedIdRevs} = couch_db:fold_purge_infos(
            Db2,
            0,
            fun fold_fun/2,
            [],
            []
        ),
        ?assertEqual(PurgedDocsNum, length(PurgedIdRevs)),

        % run compaction to trigger pruning of purge tree
        {ok, Db3} = couch_db:open_int(DbName, []),
        {ok, _CompactPid} = couch_db:start_compact(Db3),
        wait_compaction(DbName, "database", ?LINE),
        ok = couch_db:close(Db3),

        % check the remaining purge requests in purge tree
        {ok, Db4} = couch_db:reopen(Db3),
        OldestPSeq = couch_db:get_oldest_purge_seq(Db4),
        {ok, PurgedIdRevs2} = couch_db:fold_purge_infos(
            Db4,
            OldestPSeq - 1,
            fun fold_fun/2,
            [],
            []
        ),
        ?assertEqual(PurgedDocsLimit, length(PurgedIdRevs2))
    end).

test_purge_compact_for_stale_purge_cp_with_client(Db) ->
    ?_test(begin
        DbName = couch_db:name(Db),
        % add more documents to database for purge
        Docs = couch_mrview_test_util:make_docs(normal, 6, 200),
        {ok, Db1} = couch_mrview_test_util:save_docs(Db, Docs),

        % change PurgedDocsLimit to 10 from 1000 to
        % avoid timeout of eunit test
        PurgedDocsLimit = 10,
        couch_db:set_purge_infos_limit(Db1, PurgedDocsLimit),
        _Result = run_query(Db1, []),

        % first purge 30 documents
        PurgedDocsNum1 = 30,
        IdsRevs = lists:foldl(
            fun(Id, CIdRevs) ->
                Id1 = docid(Id),
                FDI1 = couch_db:get_full_doc_info(Db1, Id1),
                Rev1 = get_rev(FDI1),
                UUID1 = uuid(Id),
                [{UUID1, Id1, [Rev1]} | CIdRevs]
            end,
            [],
            lists:seq(1, PurgedDocsNum1)
        ),
        {ok, _} = couch_db:purge_docs(Db1, IdsRevs),

        {ok, Db2} = couch_db:reopen(Db1),
        % run query again to reflect purge request to mrview
        _Result1 = run_query(Db2, []),
        {ok, PurgedIdRevs} = couch_db:fold_purge_infos(
            Db2,
            0,
            fun fold_fun/2,
            [],
            []
        ),
        ?assertEqual(PurgedDocsNum1, length(PurgedIdRevs)),

        % then purge 120 documents
        PurgedDocsNum2 = 150,
        IdsRevs2 = lists:foldl(
            fun(Id, CIdRevs) ->
                Id1 = docid(Id),
                FDI1 = couch_db:get_full_doc_info(Db1, Id1),
                Rev1 = get_rev(FDI1),
                UUID1 = uuid(Id),
                [{UUID1, Id1, [Rev1]} | CIdRevs]
            end,
            [],
            lists:seq(PurgedDocsNum1 + 1, PurgedDocsNum2)
        ),
        {ok, _} = couch_db:purge_docs(Db2, IdsRevs2),

        % run compaction to trigger pruning of purge tree
        % only the first 30 purge requests are pruned
        {ok, Db3} = couch_db:open_int(DbName, []),
        {ok, _CompactPid} = couch_db:start_compact(Db3),
        wait_compaction(DbName, "database", ?LINE),
        ok = couch_db:close(Db3),

        % check the remaining purge requests in purge tree
        {ok, Db4} = couch_db:reopen(Db3),
        OldestPSeq = couch_db:get_oldest_purge_seq(Db4),
        {ok, PurgedIdRevs2} = couch_db:fold_purge_infos(
            Db4,
            OldestPSeq - 1,
            fun fold_fun/2,
            [],
            []
        ),
        ?assertEqual(PurgedDocsNum2 - PurgedDocsNum1, length(PurgedIdRevs2))
    end).

get_local_purge_doc(Db) ->
    {ok, DDoc} = couch_db:open_doc(Db, <<"_design/bar">>, []),
    {ok, IdxState} = couch_mrview_util:ddoc_to_mrst(couch_db:name(Db), DDoc),
    Sig = IdxState#mrst.sig,
    HexSig = list_to_binary(couch_index_util:hexsig(Sig)),
    DocId = couch_mrview_util:get_local_purge_doc_id(HexSig),
    couch_db:open_doc(Db, DocId, []).

run_query(Db, Opts) ->
    couch_mrview:query_view(Db, <<"_design/bar">>, <<"baz">>, Opts).

save_docs(Db, JsonDocs, Options) ->
    Docs = lists:map(
        fun(JDoc) ->
            couch_doc:from_json_obj(?JSON_DECODE(?JSON_ENCODE(JDoc)))
        end,
        JsonDocs
    ),
    Opts = [full_commit | Options],
    case lists:member(?REPLICATED_CHANGES, Options) of
        true ->
            {ok, []} = couch_db:update_docs(
                Db, Docs, Opts, ?REPLICATED_CHANGES
            ),
            {ok,
                lists:map(
                    fun(Doc) ->
                        {Pos, [RevId | _]} = Doc#doc.revs,
                        {Pos, RevId}
                    end,
                    Docs
                )};
        false ->
            {ok, Resp} = couch_db:update_docs(Db, Docs, Opts),
            {ok, [Rev || {ok, Rev} <- Resp]}
    end.

get_rev(#full_doc_info{} = FDI) ->
    #doc_info{
        revs = [#rev_info{} = PrevRev | _]
    } = couch_doc:to_doc_info(FDI),
    PrevRev#rev_info.rev.

db_disk_size(DbName) ->
    {ok, Db} = couch_db:open_int(DbName, []),
    {ok, Info} = couch_db:get_db_info(Db),
    ok = couch_db:close(Db),
    active_size(Info).

active_size(Info) ->
    couch_util:get_nested_json_value({Info}, [sizes, active]).

wait_compaction(DbName, Kind, Line) ->
    WaitFun = fun() ->
        case is_compaction_running(DbName) of
            true -> wait;
            false -> ok
        end
    end,
    case test_util:wait(WaitFun, 10000) of
        timeout ->
            error(
                {assertion_failed, [
                    {module, ?MODULE},
                    {line, Line},
                    {reason,
                        "Timeout waiting for " ++
                            Kind ++
                            " database compaction"}
                ]}
            );
        _ ->
            ok
    end.

is_compaction_running(DbName) ->
    {ok, Db} = couch_db:open_int(DbName, []),
    {ok, DbInfo} = couch_db:get_db_info(Db),
    couch_db:close(Db),
    couch_util:get_value(compact_running, DbInfo).

fold_fun({_PSeq, _UUID, Id, Revs}, Acc) ->
    {ok, [{Id, Revs} | Acc]}.

docid(I) ->
    integer_to_binary(I).

uuid(I) ->
    Str = io_lib:format("UUID~4..0b", [I]),
    iolist_to_binary(Str).
