/*
 * 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.utils;
@safe:

import std.stdio : File, write, writeln;
import std.string;
import std.ascii : letters, digits;
import std.conv : to;
import std.random : randomSample;
import std.range : chain;
import std.algorithm : startsWith;
import std.array : appender, empty;
import std.path : buildPath, dirName, buildNormalizedPath;
import std.typecons : Nullable, Rebindable;
import std.traits : Unqual, hasIndirections;
static import std.file;

import glib.Bytes : Bytes;
import appstream.Component;
import appstream.Icon;
static import appstream.Utils;

alias AsUtils = appstream.Utils.Utils;

import asgen.defines;
import asgen.logging;
import asgen.downloader : Downloader;

public immutable DESKTOP_GROUP = "Desktop Entry";

public immutable GENERIC_BUFFER_SIZE = 8192;

struct ImageSize {
    uint width;
    uint height;
    uint scale;

    this (uint w, uint h, uint s)
    {
        width = w;
        height = h;
        scale = s;
    }

    this (uint w, uint h)
    {
        width = w;
        height = h;
        scale = 1;
    }

    this (uint s)
    {
        width = s;
        height = s;
        scale = 1;
    }

    this (string str)
    {
        width = -1;
        height = -1;
        scale = -1;

        immutable sep = str.indexOf("x");
        if (sep <= 0)
            return;

        immutable scaleSep = str.indexOf("@");
        width = str[0 .. sep].to!uint;
        if (scaleSep <= 0) {
            scale = 1;
            height = str[sep + 1 .. $].to!uint;
        } else {
            if (scaleSep == str.length)
                throw new Exception("Image size string must not end with '@'.");
            height = str[sep + 1 .. scaleSep].to!uint;
            scale = str[scaleSep + 1 .. $].to!uint;
        }
    }

    string toString () const
    {
        if (scale == 1)
            return "%ux%u".format(width, height);
        else
            return "%ux%u@%u".format(width, height, scale);
    }

    uint toInt () const
    {
        if (width > height)
            return width * scale;
        return height * scale;
    }

    int opCmp (const ImageSize s) const
    {
        // only compares width, should be enough for now
        if (this.width > s.width)
            return 1;
        else if (this.width < s.width)
            return -1;

        if (this.scale > s.scale)
            return 1;
        else if (this.scale < s.scale)
            return -1;

        return 0;
    }
}

/**
 * Generate a random alphanumeric string.
 */
@trusted
string randomString (uint len)
{
    auto asciiLetters = to!(dchar[])(letters);
    auto asciiDigits = to!(dchar[])(digits);

    if (len == 0)
        len = 1;

    auto res = to!string(randomSample(chain(asciiLetters, asciiDigits), len));
    return res;
}

/**
 * Check if the locale is a valid locale which we want to include
 * in the resulting metadata. Some locales added just for testing
 * by upstreams should be filtered out.
 */
@safe
bool localeValid (string locale) pure
{
    switch (locale) {
        case "x-test":
        case "xx":
            return false;
        default:
            return true;
    }
}

/**
 * Check if the given string is a top-level domain name.
 * The TLD list of AppStream is incomplete, but it will
 * cover 99% of all cases.
 * (in a short check on Debian, it covered all TLDs in use there)
 */
@trusted
bool isTopLevelDomain (const string value)
{
    if (value.empty)
        return false;

    return AsUtils.isTld(value);
}

/**
 * Get the component-id back from a global component-id.
 */
@trusted
string getCidFromGlobalID (string gcid)
{
    auto parts = gcid.split("/");
    if (parts.length != 4)
        return null;
    if (isTopLevelDomain(parts[0])) {
        return join(parts[0 .. 3], ".");
    }

    return parts[2];
}

@trusted
void hardlink (const string srcFname, const string destFname)
{
    import core.sys.posix.unistd;
    import core.stdc.string;
    import core.stdc.errno;

    immutable res = link(srcFname.toStringz, destFname.toStringz);
    if (res != 0)
        throw new std.file.FileException("Unable to create link: %s".format(errno.strerror));
}

/**
 * Copy a directory using multiple threads.
 * This function follows symbolic links,
 * and replaces them with actual directories
 * in destDir.
 *
 * Params:
 *      srcDir = Source directory to copy.
 *      destDir = Path to the destination directory.
 *      useHardlinks = Use hardlinks instead of copying files.
 */
void copyDir (in string srcDir, in string destDir, bool useHardlinks = false) @trusted
{
    import std.file;
    import std.path;
    import std.parallelism : parallel;
    import std.array : appender;

    auto deSrc = DirEntry(srcDir);
    auto files = appender!(string[]);

    if (!exists(destDir)) {
        mkdirRecurse(destDir);
    }

    auto deDest = DirEntry(destDir);
    if (!deDest.isDir()) {
        throw new FileException(deDest.name, " is not a directory");
    }

    immutable destRoot = deDest.name ~ '/';

    if (!deSrc.isDir()) {
        if (useHardlinks)
            hardlink (deSrc.name, destRoot);
        else
            std.file.copy(deSrc.name, destRoot);
    } else {
        auto srcLen = deSrc.name.length;
        if (!std.file.exists(destRoot))
            mkdir (destRoot);

        // make an array of the regular files and create the directory structure
        // Since it is SpanMode.breadth, we can just use mkdir
        foreach (DirEntry e; dirEntries(deSrc.name, SpanMode.breadth, true)) {
            if (attrIsDir(e.attributes)) {
                auto childDir = destRoot ~ e.name[srcLen .. $];
                mkdirRecurse(childDir);
            } else {
                files ~= e.name;
            }
        }

        // parallel foreach for regular files
        foreach (fn; parallel(files.data, 100)) {
            immutable destFn = destRoot ~ fn[srcLen .. $];

            if (useHardlinks)
                hardlink (fn, destFn);
            else
                std.file.copy(fn, destFn);
        }
    }
}

/**
 * Escape XML characters.
 */
@safe
S escapeXml (S)(S s) pure
{
    string r;
    size_t lastI;
    auto result = appender!S();

    foreach (i, c; s) {
        switch (c) {
            case '&':
                r = "&amp;";
                break;
            case '"':
                r = "&quot;";
                break;
            case '\'':
                r = "&apos;";
                break;
            case '<':
                r = "&lt;";
                break;
            case '>':
                r = "&gt;";
                break;
            default:
                continue;
        }

        // Replace with r
        result.put(s[lastI .. i]);
        result.put(r);
        lastI = i + 1;
    }

    if (!result.data.ptr)
        return s;
    result.put(s[lastI .. $]);
    return result.data;
}

/**
 * Get full path for an AppStream generator data file.
 */
@safe
string getDataPath (string fname)
{
    import std.path : buildNormalizedPath, dirName;

    immutable exeDirName = std.file.thisExePath.dirName;

    // useful for testing
    if (!exeDirName.startsWith("/usr")) {
        auto resPath = buildNormalizedPath(exeDirName, "..", "..", "data", fname);
        if (std.file.exists(resPath))
            return resPath;
        resPath = buildNormalizedPath(exeDirName, "..", "..", "..", "data", fname);
        if (std.file.exists(resPath))
            return resPath;
    }

    auto resPath = buildPath(DATADIR, fname);
    if (std.file.exists(resPath))
        return resPath;

    resPath = buildNormalizedPath(exeDirName, "..", "data", fname);
    if (std.file.exists(resPath))
        return resPath;

    resPath = buildPath("data", fname);
    if (std.file.exists(resPath))
        return resPath;

    // Uh, let's just give up
    return buildPath("/usr", "share", "appstream", fname);
}

/**
 * Check if a path exists and is a directory.
 */
bool existsAndIsDir (string path) @safe
{
    if (std.file.exists(path))
        if (std.file.isDir(path))
            return true;
    return false;
}

/**
 * Convert a string array into a byte array.
 */
ubyte[] stringArrayToByteArray (string[] strArray) pure @trusted
{
    auto res = appender!(ubyte[]);
    res.reserve(strArray.length * 2); // make a guess, we will likely need much more space

    foreach (ref s; strArray) {
        res ~= cast(ubyte[]) s;
    }

    return res.data;
}

/**
 * Check if string contains a remote URI.
 */
@safe
bool isRemote (const string uri)
{
    import std.regex : ctRegex, matchFirst;

    auto uriregex = ctRegex!(`^(https?|ftps?)://`);
    auto match = matchFirst(uri, uriregex);

    return (!match.empty);
}

/**
 * Download or open `path` and return it as a string array.
 *
 * Params:
 *      path = The path to access.
 *
 * Returns: The data if successful.
 */
string[] getTextFileContents (const string path, const uint maxTryCount = 4, Downloader downloader = null) @trusted
{
    import core.stdc.stdlib : free;
    import core.sys.posix.stdio : fclose, open_memstream;

    if (path.isRemote) {
        if (downloader is null)
            downloader = Downloader.get;
        return downloader.downloadTextLines(path, maxTryCount);
    } else {
        if (!std.file.exists(path))
            throw new Exception("No such file '%s'", path);

        return std.file.readText(path).splitLines;
    }
}

/**
 * Download or open `path` and return it as a byte array.
 *
 * Params:
 *      path = The path to access.
 *
 * Returns: The data if successful.
 */
immutable(ubyte[]) getFileContents (const string path, const uint maxTryCount = 4, Downloader downloader = null) @trusted
{
    import core.stdc.stdlib : free;
    import core.sys.posix.stdio : fclose, open_memstream;

    if (path.isRemote) {
        if (downloader is null)
            downloader = Downloader.get;
        return downloader.download(path, maxTryCount);
    } else {
        if (!std.file.exists(path))
            throw new Exception("No such file '%s'", path);

        ubyte[] data;
        auto f = File(path, "r");
        while (!f.eof) {
            char[GENERIC_BUFFER_SIZE] buf;
            data ~= f.rawRead(buf);
        }

        return cast(immutable ubyte[]) data;
    }
}

/**
 * Get path of the directory with test samples.
 * The function will look for test data in the current
 * working directory.
 */
string getTestSamplesDir () @trusted
{
    import std.file : getcwd;

    auto path = buildPath(getcwd(), "tests", "samples");
    if (std.file.exists(path))
        return path;
    path = buildNormalizedPath(getcwd(), "..", "tests", "samples");

    return path;
}

/**
 * Return a suitable, "raw" icon name (either a stock icon name or local icon)
 * for this component that can be processed further by the generator.
 * Return null if this component does not have a suitable icon.
 */
@system
Nullable!Icon componentGetRawIcon (ref Component cpt)
{
    import gobject.c.functions : g_object_ref;

    Nullable!Icon iconLocal;
    auto iconsArr = cpt.getIcons();

    for (uint i = 0; i < iconsArr.len; i++) {
        // cast array data to D AsIcon and keep a reference to the C struct
        auto icon = new Icon(cast(AsIcon*) g_object_ref (iconsArr.index(i)), true);
        if (icon.getKind == IconKind.STOCK) {
            Nullable!Icon iconStock = icon;
            return iconStock;
        }
        if (icon.getKind == IconKind.LOCAL)
            iconLocal = icon;
    }

    // only return local icon if we had no stock icon
    return iconLocal;
}

template StoredType(T) {
    static if (is(T == immutable) || is(T == const)) {
        static if (is(T == class)) {
            alias StoredType = Rebindable!T;
        } else {
            alias StoredType = Unqual!T;
        }
    } else {
        alias StoredType = T;
    }
}

bool UseGCRanges (T)()
{
    return hasIndirections!T;
}

bool UseGCRanges (Allocator, T, bool GCRangesAllowed)()
{
    import std.experimental.allocator.gc_allocator;

    return  !is(Allocator == GCAllocator) && hasIndirections!T && GCRangesAllowed;
}

bool UseGCRanges (Allocator, K, V, bool GCRangesAllowed)()
{
    import std.experimental.allocator.gc_allocator;

    return  !is(Allocator == GCAllocator) && (hasIndirections!K || hasIndirections!V) && GCRangesAllowed;
}

string filenameFromURI (string uri)
{
    import std.path : baseName;
    import std.string : indexOf;

    auto bname = uri.baseName;
    immutable qInd = bname.indexOf("?");
    if (qInd > 0)
        bname = bname[0 .. qInd];
    immutable hInd = bname.indexOf("#");
    if (hInd > 0)
        bname = bname[0 .. hInd];

    return bname;
}

auto toGBytesCopy(const(ubyte)[] data) @trusted
{
    import glib.c.functions : g_bytes_new_take, g_memdup;

    auto nc = cast(ubyte[]) data;
    // FIXME: We should use g_memdup2 here, once we can bump the GLib version!
    void* ncCopy = g_memdup(nc.ptr, cast(uint) nc.length);
    auto gBytes = g_bytes_new_take(ncCopy, cast(size_t) nc.length);
    return new Bytes(gBytes, true);
}

auto toStaticGBytes(const(ubyte)[] data) @trusted
{
    import glib.c.functions : g_bytes_new_static;

    auto nc = cast(ubyte[]) data;
    auto gBytes = g_bytes_new_static(nc.ptr, cast(size_t) nc.length);
    return new Bytes(gBytes, true);
}

auto toStaticGBytes(ubyte[] data) @trusted
{
    import glib.c.functions : g_bytes_new_static;

    auto gBytes = g_bytes_new_static(data.ptr, cast(size_t) data.length);
    return new Bytes(gBytes, true);
}

auto toStaticGBytes(const string data) @trusted
{
    return toStaticGBytes(cast(immutable(ubyte)[]) data.representation);
}

@trusted
unittest {
    import std.exception : assertThrown;

    writeln("TEST: ", "Utils");

    assert(getCidFromGlobalID("f/fo/foobar.desktop/DEADBEEF") == "foobar.desktop");
    assert(getCidFromGlobalID("org/gnome/yelp.desktop/DEADBEEF") == "org.gnome.yelp.desktop");

    assert(ImageSize(80, 40).toString() == "80x40");
    assert(ImageSize(1024, 420).toInt() == 1024);
    assert(ImageSize(1024, 800) > ImageSize(64, 32));
    assert(ImageSize(48) < ImageSize(64));

    assert(ImageSize("64x64") == ImageSize(64));
    assert(ImageSize("64x64@2") == ImageSize(64, 64, 2));
    assert(ImageSize("128x128@2") == ImageSize(128, 128, 2));
    assertThrown!Exception(ImageSize("48x48@"));
    assert(ImageSize("x23@2").height == -1);
    logInfo("Size: %s", ImageSize("64x64"));

    assert(stringArrayToByteArray(["A", "b", "C", "ö", "8"]) == [65, 98, 67, 195, 182, 56]);

    assert(isRemote("http://test.com"));
    assert(isRemote("https://example.org"));
    assert(!isRemote("/srv/mirror"));
    assert(!isRemote("file:///srv/test"));

    assert(filenameFromURI("https://example.org/test/filename.txt") == "filename.txt");
    assert(filenameFromURI("https://example.org/test/video.mkv?raw=true") == "video.mkv");
    assert(filenameFromURI("https://example.org/test/video.mkv#anchor") == "video.mkv");
    assert(filenameFromURI("https://example.org/test/video.mkv?raw=true&aaa=bbb") == "video.mkv");
}
