module api_modules.auth;

import handy_httpd;
import handy_httpd.components.optional;
import handy_httpd.handlers.path_handler;
import slf4d;
import std.algorithm : map;
import std.array : array;
import std.json;
import ddbc;

import db;
import data_utils;

struct User {
    const ulong id;
    const string username;
    const string passwordHash;
    const ulong createdAt;
    const bool isLocked;
    const bool isAdmin;

    static User parse(DataSetReader r) {
        return User(
            r.getUlong(1),
            r.getString(2),
            r.getString(3),
            r.getUlong(4),
            r.getBoolean(5),
            r.getBoolean(6)
        );
    }
}

private struct UserResponse {
    ulong id;
    string username;
    ulong createdAt;
    bool isLocked;
    bool isAdmin;
}

void registerApiEndpoints(PathHandler handler) {
    handler.addMapping(Method.POST, "/api/auth/login", &loginEndpoint);
    handler.addMapping(Method.GET, "/api/auth/admin/users", &usersAdminEndpoint);
    handler.addMapping(Method.POST, "/api/auth/admin/users", &createUserAdminEndpoint);
    handler.addMapping(Method.DELETE, "/api/auth/admin/users/:userId:ulong", &deleteUserAdminEndpoint);
    handler.addMapping(Method.PUT, "/api/auth/admin/users/:userId:ulong", &updateUserAdminEndpoint);
}

private string encodePassword(string password) {
    import std.digest.sha;
    return toHexString(sha256Of(password)).idup;
}

private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, Connection conn) {
    import std.base64;
    import std.string : startsWith;
    import std.digest.sha;
    import std.algorithm : countUntil;

    string headerStr = ctx.request.headers.getFirst("Authorization").orElse("");
    if (headerStr.length == 0 || !startsWith(headerStr, "Basic ")) {
        return Optional!User.empty;
    }
    string encodedCredentials = headerStr[6..$];
    string decoded = cast(string) Base64.decode(encodedCredentials);
    size_t idx = countUntil(decoded, ':');
    string username = decoded[0..idx];
    auto passwordHash = encodePassword(decoded[idx+1 .. $]);
    Optional!User optUser = findOne(
        conn,
        "SELECT * FROM auth_user WHERE username = ?",
        &User.parse,
        username
    );
    if ( // Reject the user's authentication, even if they exist, if:
        !optUser.isNull &&
        (
            optUser.value.passwordHash != passwordHash || // Password doesn't match.
            optUser.value.isLocked                        // Account is locked.
        )
    ) {
        return Optional!User.empty;
    }
    return optUser;
}

/**
 * Gets the current user for a given request context, based on their basic
 * authentication header.
 * Params:
 *   ctx = The request context.
 *   conn = The database connection.
 * Returns: The user that made the request. Otherwise, a 401 is thrown.
 */
User getUserOrThrow(ref HttpRequestContext ctx, Connection conn) {
    Optional!User optUser = getUserFromBasicAuth(ctx, conn);
    if (optUser.isNull) {
        throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
    }
    return optUser.value;
}

/**
 * Similar to `getUserOrThrow`, but throws a 403 if the user isn't an admin.
 * Params:
 *   ctx = The request context.
 *   conn = The database connection.
 * Returns: The user that made the request.
 */
User getAdminUserOrThrow(ref HttpRequestContext ctx, Connection conn) {
    User user = getUserOrThrow(ctx, conn);
    if (!user.isAdmin) {
        throw new HttpStatusException(HttpStatus.FORBIDDEN, "Forbidden from accessing this resource.");
    }
    return user;
}

private void loginEndpoint(ref HttpRequestContext ctx) {
    Connection conn = getDb();
    scope(exit) conn.close();
    Optional!User optUser = getUserFromBasicAuth(ctx, conn);
    if (optUser.isNull) {
        ctx.response.status = HttpStatus.UNAUTHORIZED;
        ctx.response.writeBodyString("Invalid credentials.");
        return;
    }
    infoF!"Login successful for user \"%s\"."(optUser.value.username);
    writeJsonBody(ctx, UserResponse(
        optUser.value.id,
        optUser.value.username,
        optUser.value.createdAt,
        optUser.value.isLocked,
        optUser.value.isAdmin
    ));
}

private void usersAdminEndpoint(ref HttpRequestContext ctx) {
    Connection conn = getDb();
    scope(exit) conn.close();
    User user = getAdminUserOrThrow(ctx, conn);
    uint page = ctx.request.getParamAs!uint("page", 0);
    uint pageSize = ctx.request.getParamAs!uint("size", 30);
    if (page < 0) page = 0;
    if (pageSize < 5) pageSize = 5;
    if (pageSize > 100) pageSize = 100;
    uint offset = page * pageSize;

    const query = "SELECT * FROM auth_user ORDER BY created_at DESC LIMIT ? OFFSET ?";
    UserResponse[] users = findAll(
        conn,
        query,
        &User.parse,
        pageSize, offset
    )
        .map!(u => UserResponse(u.id, u.username, u.createdAt, u.isLocked, u.isAdmin))
        .array;
    writeJsonBody(ctx, users);
}

private void createUserAdminEndpoint(ref HttpRequestContext ctx) {
    Connection conn = getDb();
    scope(exit) conn.close();
    User user = getAdminUserOrThrow(ctx, conn);
    struct Payload {
        string username;
        string password;
    }
    Payload payload = readJsonPayload!(Payload)(ctx);
    // TODO: Validate data
    string passwordHash = encodePassword(payload.password);
    const query = "
        INSERT INTO auth_user (
            username,
            password_hash,
            is_locked,
            is_admin
        ) VALUES (?, ?, ?, ?)
        RETURNING id
    ";
    ulong newUserId = insertOne(
        conn,
        query,
        payload.username, passwordHash, false, false
    );
    User newUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, newUserId).orElseThrow();
    writeJsonBody(ctx, UserResponse(
        newUser.id,
        newUser.username,
        newUser.createdAt,
        newUser.isLocked,
        newUser.isAdmin
    ));
}

private void deleteUserAdminEndpoint(ref HttpRequestContext ctx) {
    Connection conn = getDb();
    scope(exit) conn.close();
    User user = getAdminUserOrThrow(ctx, conn);
    ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
    Optional!User targetUser = findOne(
        conn,
        "SELECT * FROM auth_user WHERE id = ?",
        &User.parse,
        targetUserId
    );
    if (!targetUser.isNull) {
        update(conn, "DELETE FROM auth_user WHERE id = ?", targetUserId);
    }
}

private void updateUserAdminEndpoint(ref HttpRequestContext ctx) {
    Connection conn = getDb();
    scope(exit) conn.close();
    conn.setAutoCommit(false);
    User user = getAdminUserOrThrow(ctx, conn);
    ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
    Optional!User targetUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId);
    if (targetUser.isNull) {
        ctx.response.status = HttpStatus.NOT_FOUND;
        ctx.response.writeBodyString("User not found.");
        return;
    }
    JSONValue payload = ctx.request.readBodyAsJson();
    if (payload.type != JSONType.object) {
        ctx.response.status = HttpStatus.BAD_REQUEST;
        ctx.response.writeBodyString("Expected JSON object with user properties.");
        return;
    }
    try {
        if ("username" in payload.object) {
            string newUsername = payload.object["username"].str;
            if (newUsername != targetUser.value.username) {
                if (newUsername.length < 3 || newUsername.length > 32) {
                    ctx.response.status = HttpStatus.BAD_REQUEST;
                    ctx.response.writeBodyString("Invalid username.");
                    conn.rollback();
                    return;
                }
                if (recordExists(conn, "SELECT id FROM auth_user WHERE username = ?", newUsername)) {
                    ctx.response.status = HttpStatus.BAD_REQUEST;
                    ctx.response.writeBodyString("Username already taken.");
                    conn.rollback();
                    return;
                }
                update(conn, "UPDATE auth_user SET username = ? WHERE id = ?", newUsername, targetUserId);
            }
        }
        if ("password" in payload.object) {
            string rawPassword = payload.object["password"].str;
            string newPasswordHash = encodePassword(rawPassword);
            if (newPasswordHash != targetUser.value.passwordHash) {
                update(conn, "UPDATE auth_user SET password_hash = ? WHERE id = ?", newPasswordHash, targetUserId);
            }
        }
        if ("isLocked" in payload.object) {
            bool newIsLocked = payload.object["isLocked"].boolean;
            if (newIsLocked != targetUser.value.isLocked) {
                update(conn, "UPDATE auth_user SET is_locked = ? WHERE id = ?", newIsLocked, targetUserId);
            }
        }
        conn.commit();
        User updatedUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId)
            .orElseThrow();
        writeJsonBody(ctx, UserResponse(
            updatedUser.id,
            updatedUser.username,
            updatedUser.createdAt,
            updatedUser.isLocked,
            updatedUser.isAdmin
        ));
    } catch (Exception e) {
        conn.rollback();
        ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
        ctx.response.writeBodyString("Something went wrong: " ~ e.msg);
    }
}