module util.sqlite;

import std.datetime;

import slf4d;
import d2sqlite3;
import handy_http_primitives : Optional;

/**
 * Tries to find a single row from a database.
 * Params:
 *   db = The database to use.
 *   query = The query to execute.
 *   resultMapper = A function to map rows to the desired result type.
 *   args = Arguments for the query.
 * Returns: An optional result.
 */
Optional!T findOne(T, Args...)(Database db, string query, T delegate(Row) resultMapper, Args args) {
    Statement stmt = db.prepare(query);
    stmt.bindAll(args);
    ResultRange result = stmt.execute();
    if (result.empty) return Optional!T.empty;
    return Optional!T.of(resultMapper(result.front));
}
/// Overload that accepts a function.
Optional!T findOne(T, Args...)(Database db, string query, T function(Row) resultMapper, Args args) {
    Statement stmt = db.prepare(query);
    stmt.bindAll(args);
    ResultRange result = stmt.execute();
    if (result.empty) return Optional!T.empty;
    return Optional!T.of(resultMapper(result.front));
}

/**
 * Tries to find a single entity by its id, selecting all properties.
 * Params:
 *   db = The database to use.
 *   table = The table to select from.
 *   resultMapper = A function to map rows to the desired result type.
 *   id = The entity's id.
 * Returns: An optional result.
 */
Optional!T findById(T)(Database db, string table, T function(Row) resultMapper, ulong id) {
    Statement stmt = db.prepare("SELECT * FROM " ~ table ~ " WHERE id = ?");
    stmt.bind(1, id);
    ResultRange result = stmt.execute();
    if (result.empty) return Optional!T.empty;
    return Optional!T.of(resultMapper(result.front));
}

/**
 * Finds a list of records from a database.
 * Params:
 *   db = The database to use.
 *   query = The query to execute.
 *   resultMapper = A function to map rows to the desired result type.
 *   args = Arguments for the query.
 * Returns: A list of results.
 */
T[] findAll(T, Args...)(Database db, string query, T function(Row) resultMapper, Args args) {
    Statement stmt = db.prepare(query);
    stmt.bindAll(args);
    import std.algorithm : map;
    import std.array : array;
    return stmt.execute().map!(r => resultMapper(r)).array;
}

/**
 * Finds a list of records from a database, using a single function to parse
 * the entire result set at once, useful for cases where records may be spread
 * over multiple rows due to joined properties.
 * Params:
 *   db = The database to use.
 *   query = The query to execute.
 *   resultMapper = A function to map the result range to the list of results.
 *   args = Arguments for the query.
 * Returns: A list of results.
 */
T[] findAllDirect(T, Args...)(Database db, string query, T[] function(ResultRange) resultMapper, Args args) {
    Statement stmt = db.prepare(query);
    stmt.bindAll(args);
    return resultMapper(stmt.execute());
}

/**
 * Determines if at least one record exists.
 * Params:
 *   db = The database to use.
 *   query = The query to execute.
 *   args = The arguments for the query.
 * Returns: True if at least one record is returned, or false if not.
 */
bool exists(Args...)(Database db, string query, Args args) {
    Statement stmt = db.prepare(query);
    stmt.bindAll(args);
    return !stmt.execute().empty();
}

/**
 * Performs an update (UPDATE/INSERT/DELETE).
 * Params:
 *   db = The database to use.
 *   query = The query to execute.
 *   args = The arguments for the query.
 * Returns: The number of rows that were affected.
 */
int update(Args...)(Database db, string query, Args args) {
    Statement stmt = db.prepare(query);
    stmt.bindAll(args);
    stmt.execute();
    return db.changes();
}

/**
 * Deletes an entity from a table.
 * Params:
 *   db = The database to use.
 *   table = The table to delete from.
 *   id = The id of the entity to delete.
 */
void deleteById(Database db, string table, ulong id) {
    Statement stmt = db.prepare("DELETE FROM " ~ table ~ " WHERE id = ?");
    stmt.bind(1, id);
    stmt.execute();
}

/**
 * Wraps a given delegate block of code in an SQL transaction, so that all
 * operations will be committed at once when done. If an exception is thrown,
 * then the changes will be rolled back.
 * Params:
 *   db = The database to use.
 *   dg = The delegate block of code to run in the transaction.
 * Returns: The return value of the delegate, if the delegate does indeed
 * return something.
 */
T doTransaction(T)(Database db, T delegate() dg) {
    try {
        db.begin();
        static if (is(T : void)) {
            dg();
        } else {
            T result = dg();
        }
        db.commit();
        static if (!is(T : void)) return result;
    } catch (Exception e) {
        error("Rolling back transaction due to exception.", e);
        db.rollback();
        throw e;
    }
}

/**
 * Executes a "SELECT COUNT..." query on a database.
 * Params:
 *   db = The database to use.
 *   query = The query to use, which must return an integer as its sole result.
 *   args = Arguments to provide to the query.
 * Returns: The count returned by the query.
 */
ulong count(Args...)(Database db, string query, Args args) {
    Statement stmt = db.prepare(query);
    stmt.bindAll(args);
    ResultRange result = stmt.execute();
    if (result.empty) return 0;
    return result.front.peek!ulong(0);
}

/**
 * Reads an ISO-8601 UTC timestamp from a result row.
 * Params:
 *   row = The row to read from.
 *   idx = The column index in the row to read.
 * Returns: The timestamp that was read.
 */
SysTime parseISOTimestamp(Row row, size_t idx) {
    return SysTime.fromISOExtString(
        row.peek!(string, PeekMode.slice)(idx),
        UTC()
    );
}

/**
 * Reads a set of bytes from a result row.
 * Params:
 *   row = The row to read from.
 *   idx = The column index in the row to read.
 * Returns: The blob data that was read.
 */
immutable(ubyte[]) parseBlob(Row row, size_t idx) {
    return row.peek!(ubyte[], PeekMode.slice)(idx).idup;
}

/**
 * Reads an Optional primitive value from a result row.
 * Params:
 *   row = The row to read from.
 *   idx = The column index in the row to read.
 * Returns: An optional containing the data, if present.
 */
Optional!T parseOptional(T, PeekMode mode = PeekMode.copy)(Row row, size_t idx) {
    import std.typecons : Nullable;
    import std.traits : isSomeString;
    static if (isSomeString!T) {
        Nullable!T n = row.peek!(Nullable!T, mode)(idx);
    } else {
        Nullable!T n = row.peek!(Nullable!T)(idx);
    }
    if (n.isNull) return Optional!(T).empty();
    static if (isSomeString!T) {
        // If the string value is null, return empty as well.
        if (n.get() is null) return Optional!(T).empty();
    }
    return Optional!(T).of(n.get());
}

struct QueryBuilder {
    string fromTable;
    string[] selections;
    string[] joins;
    string[] conditions;
    string[] groupings;
    void delegate(ref Statement, ref int)[] argBinders;

    this(string fromTable) {
        this.fromTable = fromTable;
    }

    ref select(string expr) {
        selections ~= expr;
        return this;
    }

    ref join(string expr) {
        joins ~= expr;
        return this;
    }

    ref where(string expr) {
        conditions ~= expr;
        return this;
    }

    ref withArgBinding(void delegate(ref Statement, ref int) dg) {
        argBinders ~= dg;
        return this;
    }

    ref groupBy(string grouping) {
        groupings ~= grouping;
        return this;
    }

    string build() const {
        import std.algorithm : map;
        import std.string : join;
        import std.array : appender;
        auto app = appender!string;
        app ~= "SELECT\n";
        if (selections.length > 0) {
            app ~= selections.map!(s => "  " ~ s).join(",\n");
        } else {
            app ~= "  *";
        }
        app ~= "\nFROM " ~ fromTable ~ "\n";
        app ~= joins.join("\n");
        if (conditions.length > 0) {
            app ~= "\nWHERE\n";
            app ~= conditions.map!(s => "  " ~ s).join(" AND\n");
        }
        if (groupings.length > 0) {
            app ~= "\nGROUP BY\n";
            app ~= groupings.map!(s => "  " ~ s).join(",\n");
        }
        return app[];
    }

    void applyArgBindings(ref Statement stmt) const {
        int idx = 1;
        foreach (binding; argBinders) {
            binding(stmt, idx);
        }
    }
}
