/*
 * Copyright (C) 2016-2022 Matthias Klumpp <matthias@tenstral.net>
 *
 * Licensed under the GNU Lesser General Public License Version 3
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the license, or
 * (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this software.  If not, see <http://www.gnu.org/licenses/>.
 */

module asgen.contentsstore;

import std.stdio;
import std.string;
import std.conv : to, octal;
import std.file : mkdirRecurse;
import std.array : appender, join, split, empty;

import asgen.bindings.lmdb;
import asgen.config;
import asgen.logging;

/**
 * Contains a cache about available files in packages.
 * This is useful for finding icons and for re-scanning
 * packages which become interesting later.
 **/
final class ContentsStore {

private:
    MDB_envp dbEnv;
    MDB_dbi dbContents;
    MDB_dbi dbIcons;
    MDB_dbi dbLocale;

    bool opened;

public:

    this ()
    {
        opened = false;
    }

    ~this ()
    {
        close();
    }

    private void checkError (int rc, string msg)
    {
        if (rc != 0) {
            import std.format;

            throw new Exception(format("%s[%s]: %s", msg, rc, mdb_strerror(rc).fromStringz));
        }
    }

    void open (string dir)
    {
        static import std.math;

        int rc;
        assert(!opened);

        logDebug("Opening contents cache.");

        // ensure the cache directory exists
        mkdirRecurse(dir);

        rc = mdb_env_create(&dbEnv);
        scope (success)
            opened = true;
        scope (failure)
            dbEnv.mdb_env_close();
        checkError(rc, "mdb_env_create");

        // We are going to use at max 3 sub-databases:
        // contents, icons and locale
        rc = dbEnv.mdb_env_set_maxdbs(3);
        checkError(rc, "mdb_env_set_maxdbs");

        // set a huge map size to be futureproof.
        // This means we're cruel to non-64bit users, but this
        // software is supposed to be run on 64bit machines anyway.
        auto mapsize = cast(size_t) std.math.pow(512L, 4);
        rc = dbEnv.mdb_env_set_mapsize(mapsize);
        checkError(rc, "mdb_env_set_mapsize");

        // open database
        rc = dbEnv.mdb_env_open(dir.toStringz(),
                MDB_NOMETASYNC,
                octal!755);
        checkError(rc, "mdb_env_open");

        // open sub-databases in the environment
        MDB_txnp txn;
        rc = dbEnv.mdb_txn_begin(null, 0, &txn);
        checkError(rc, "mdb_txn_begin");
        scope (failure)
            txn.mdb_txn_abort();

        // contains a full list of all contents
        rc = txn.mdb_dbi_open("contents", MDB_CREATE, &dbContents);
        checkError(rc, "open contents database");

        // contains list of icon files and related data
        // the contents sub-database exists only to allow building instances
        // of IconHandler much faster.
        rc = txn.mdb_dbi_open("icondata", MDB_CREATE, &dbIcons);
        checkError(rc, "open icon-info database");

        // contains list of locale files and related data
        rc = txn.mdb_dbi_open("localedata", MDB_CREATE, &dbLocale);
        checkError(rc, "open locale-info database");

        rc = txn.mdb_txn_commit();
        checkError(rc, "mdb_txn_commit");
    }

    void open (Config conf)
    {
        import std.path : buildPath;

        this.open(buildPath(conf.databaseDir, "contents"));
    }

    void close ()
    {
        synchronized (this) {
            if (opened)
                dbEnv.mdb_env_close();
            opened = false;
            dbEnv = null;
        }
    }

    private MDB_val makeDbValue (const string data)
    {
        import core.stdc.string : strlen;

        MDB_val mval;
        auto cdata = data.toStringz();
        mval.mv_size = char.sizeof * strlen(cdata) + 1;
        mval.mv_data = cast(void*) cdata;
        return mval;
    }

    private MDB_txnp newTransaction (uint flags = 0)
    in {
        assert(opened);
    }
    do {
        int rc;
        MDB_txnp txn;

        rc = dbEnv.mdb_txn_begin(null, flags, &txn);
        checkError(rc, "mdb_txn_begin");

        return txn;
    }

    private void commitTransaction (MDB_txnp txn)
    {
        auto rc = txn.mdb_txn_commit();
        checkError(rc, "mdb_txn_commit");
    }

    private void quitTransaction (MDB_txnp txn)
    {
        if (txn is null)
            return;
        txn.mdb_txn_abort();
    }

    /**
     * Drop a package-id from the contents cache.
     */
    void removePackage (string pkid)
    {
        MDB_val key;

        key = makeDbValue(pkid);

        auto txn = newTransaction();
        scope (success)
            commitTransaction (txn);
        scope (failure)
            quitTransaction (txn);

        auto res = txn.mdb_del(dbContents, &key, null);
        checkError(res, "mdb_del (contents)");

        res = txn.mdb_del(dbIcons, &key, null);
        if (res != MDB_NOTFOUND)
            checkError (res, "mdb_del (icons)");

        res = txn.mdb_del(dbLocale, &key, null);
        if (res != MDB_NOTFOUND)
            checkError (res, "mdb_del (locale)");
    }

    bool packageExists (const string pkid)
    {
        MDB_val dkey;
        MDB_cursorp cur;

        dkey = makeDbValue(pkid);
        auto txn = newTransaction(MDB_RDONLY);
        scope (exit)
            quitTransaction (txn);

        auto res = txn.mdb_cursor_open(dbContents, &cur);
        scope (exit)
            cur.mdb_cursor_close();
        checkError(res, "mdb_cursor_open");

        res = cur.mdb_cursor_get(&dkey, null, MDB_SET);
        if (res == MDB_NOTFOUND)
            return false;
        checkError(res, "mdb_cursor_get");

        return true;
    }

    void addContents (string pkid, string[] contents)
    {
        // filter out icon filenames and filenames of icon-related stuff (e.g. theme.index),
        // as well as locale information
        auto iconInfo = appender!(string[]);
        auto localeInfo = appender!(string[]);
        foreach (ref f; contents) {
            if ((f.startsWith("/usr/share/icons/")) ||
                    (f.startsWith("/usr/share/pixmaps/"))) {
                iconInfo ~= f;
                continue;
            }

            // create a huge index of all Gettext and Qt translation filenames
            if (f.endsWith(".mo") || f.endsWith(".qm")) {
                localeInfo ~= f;
                continue;
            }
        }

        immutable contentsStr = contents.join("\n");

        synchronized (this) {
            MDB_val key, contentsVal;

            key = makeDbValue(pkid);
            contentsVal = makeDbValue(contentsStr);

            auto txn = newTransaction();
            scope (success)
                commitTransaction (txn);
            scope (failure)
                quitTransaction (txn);

            auto res = txn.mdb_put(dbContents, &key, &contentsVal, 0);
            checkError(res, "mdb_put");

            // if we have icon information, store that too
            if (!iconInfo.data.empty) {
                MDB_val iconsVal;

                immutable iconsStr = iconInfo.data.join("\n");
                iconsVal = makeDbValue(iconsStr);

                res = txn.mdb_put(dbIcons, &key, &iconsVal, 0);
                checkError(res, "mdb_put (icons)");
            }

            // store locale
            if (!localeInfo.data.empty) {
                MDB_val localeVal;

                immutable localeStr = localeInfo.data.join("\n");
                localeVal = makeDbValue(localeStr);

                res = txn.mdb_put(dbLocale, &key, &localeVal, 0);
                checkError(res, "mdb_put (locale)");
            }
        }
    }

    private string[string] getFilesMap (string[] pkids, MDB_dbi dbi, bool useBaseName = false)
    {
        import std.path : baseName;

        MDB_cursorp cur;

        auto txn = newTransaction(MDB_RDONLY);
        scope (exit)
            quitTransaction (txn);

        auto res = txn.mdb_cursor_open(dbi, &cur);
        scope (exit)
            cur.mdb_cursor_close();
        checkError(res, "mdb_cursor_open");

        string[string] pkgCMap;
        foreach (ref pkid; pkids) {
            MDB_val pkey = makeDbValue(pkid);
            MDB_val cval;

            res = cur.mdb_cursor_get(&pkey, &cval, MDB_SET);
            if (res == MDB_NOTFOUND)
                continue;
            checkError(res, "mdb_cursor_get");

            auto data = fromStringz(cast(char*) cval.mv_data);
            auto contents = to!string(data);

            foreach (const ref c; contents.split("\n")) {
                if (useBaseName)
                    pkgCMap[c.baseName] = pkid;
                else
                    pkgCMap[c] = pkid;
            }
        }

        return pkgCMap;
    }

    auto getContentsMap(string[] pkids)
    {
        return getFilesMap(pkids, dbContents);
    }

    auto getIconFilesMap(string[] pkids)
    {
        return getFilesMap(pkids, dbIcons);
    }

    auto getLocaleMap(string[] pkids)
    {
        // we make the assumption here that all locale for a given domain are in one package.
        // otherwise this global search will get even more insane.
        return getFilesMap(pkids, dbLocale);
    }

    private string[] getContentsList (string pkid, MDB_dbi dbi)
    {
        MDB_val pkey, cval;
        MDB_cursorp cur;

        pkey = makeDbValue(pkid);

        auto txn = newTransaction(MDB_RDONLY);
        scope (exit)
            quitTransaction (txn);

        auto res = txn.mdb_cursor_open(dbi, &cur);
        scope (exit)
            cur.mdb_cursor_close();
        checkError(res, "mdb_cursor_open");

        res = cur.mdb_cursor_get(&pkey, &cval, MDB_SET);
        if (res == MDB_NOTFOUND)
            return null;
        checkError(res, "mdb_cursor_get");

        auto data = fromStringz(cast(char*) cval.mv_data);
        auto contentsStr = to!string(data);

        return contentsStr.split("\n");
    }

    string[] getContents (string pkid)
    {
        return getContentsList(pkid, dbContents);
    }

    string[] getIcons (string pkid)
    {
        return getContentsList(pkid, dbIcons);
    }

    string[] getLocaleFiles (string pkid)
    {
        return getContentsList(pkid, dbLocale);
    }

    bool[immutable string] getPackageIdSet ()
    {
        MDB_cursorp cur;

        auto txn = newTransaction();
        scope (exit)
            quitTransaction (txn);

        auto res = txn.mdb_cursor_open(dbContents, &cur);
        scope (exit)
            cur.mdb_cursor_close();
        checkError(res, "mdb_cursor_open (getPackageIdSet)");

        bool[immutable string] pkgSet;
        MDB_val pkey;
        while (cur.mdb_cursor_get(&pkey, null, MDB_NEXT) == 0) {
            immutable pkid = to!string(fromStringz(cast(char*) pkey.mv_data));
            pkgSet[pkid] = true;
        }

        return pkgSet;
    }

    void removePackages (ref bool[immutable string] pkidSet)
    {
        auto txn = newTransaction();
        scope (success)
            commitTransaction (txn);
        scope (failure)
            quitTransaction (txn);

        foreach (ref pkid; pkidSet.byKey) {
            auto key = makeDbValue(pkid);

            auto res = txn.mdb_del(dbContents, &key, null);
            checkError(res, "mdb_del (contents)");

            res = txn.mdb_del(dbIcons, &key, null);
            if (res != MDB_NOTFOUND)
                checkError (res, "mdb_del (icons)");
            res = txn.mdb_del(dbLocale, &key, null);
            if (res != MDB_NOTFOUND)
                checkError (res, "mdb_del (locale)");
        }
    }

    void sync ()
    in {
        assert(opened);
    }
    do {
        dbEnv.mdb_env_sync(1);
    }

}
