const std = @import("std");
const zap = @import("zap.zig");

/// Authentication Scheme enum: Basic or Bearer.
pub const AuthScheme = enum {
    Basic,
    Bearer,

    pub fn str(self: AuthScheme) []const u8 {
        return switch (self) {
            .Basic => "Basic ",
            .Bearer => "Bearer ",
        };
    }

    pub fn headerFieldStrFio(self: AuthScheme) []const u8 {
        return switch (self) {
            .Basic => "authentication",
            .Bearer => "authorization",
        };
    }

    pub fn headerFieldStrHeader(self: AuthScheme) [:0]const u8 {
        return switch (self) {
            .Basic => "Authentication",
            .Bearer => "Authorization",
        };
    }
};

/// Used internally: check for presence of the requested auth header.
pub fn checkAuthHeader(scheme: AuthScheme, auth_header: []const u8) bool {
    return switch (scheme) {
        .Basic => |b| std.mem.startsWith(u8, auth_header, b.str()) and auth_header.len > b.str().len,
        .Bearer => |b| std.mem.startsWith(u8, auth_header, b.str()) and auth_header.len > b.str().len,
    };
}

/// Used internally: return the requested auth header.
pub fn extractAuthHeader(scheme: AuthScheme, r: *const zap.Request) ?[]const u8 {
    return switch (scheme) {
        .Basic => |b| r.getHeader(b.headerFieldStrFio()),
        .Bearer => |b| r.getHeader(b.headerFieldStrFio()),
    };
}

/// Decoding Strategy for Basic Authentication
const BasicAuthStrategy = enum {
    /// decode into user and pass, then check pass
    UserPass,
    /// just look up the encoded user:pass token
    Token68,
};

/// Authentication result
pub const AuthResult = enum {
    /// authentication / authorization was successful
    AuthOK,
    /// authentication / authorization failed
    AuthFailed,
    /// The authenticator handled the request that didn't pass authentication /
    /// authorization.
    /// This is used to implement authenticators that redirect to a login
    /// page. An Authenticating endpoint will not do the default, which is trying
    /// to call the `unauthorized` callback if one exists orelse ignore the request.
    Handled,
};

/// HTTP Basic Authentication RFC 7617.
/// "Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
/// user-pass strings: "$username:$password" -> base64
///
/// Notes:
///   - we only look at the Authentication header
///   - we ignore the required realm parameter
///   - we ignore the optional charset parameter
///
/// Errors:
/// WWW-Authenticate: Basic realm="this"
///
/// Lookup : any kind of map that implements get([]const u8) -> []const u8
pub fn Basic(comptime Lookup: type, comptime kind: BasicAuthStrategy) type {
    return struct {
        allocator: std.mem.Allocator,
        realm: ?[]const u8,
        lookup: *Lookup,

        const Self = @This();

        /// Creates a BasicAuth. `lookup` must implement `.get([]const u8) -> []const u8`
        /// different implementations can
        ///   - either decode, lookup and compare passwords
        ///   - or just check for existence of the base64-encoded user:pass combination
        /// if realm is provided (not null), a copy of it is taken -> call deinit() to clean up
        pub fn init(allocator: std.mem.Allocator, lookup: *Lookup, realm: ?[]const u8) !Self {
            return .{
                .allocator = allocator,
                .lookup = lookup,
                .realm = if (realm) |the_realm| try allocator.dupe(u8, the_realm) else null,
            };
        }

        /// Deinit the authenticator.
        pub fn deinit(self: *Self) void {
            if (self.realm) |the_realm| {
                self.allocator.free(the_realm);
            }
        }

        /// Use this to decode the auth_header into user:pass, lookup pass in lookup.
        /// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
        pub fn authenticateUserPass(self: *Self, auth_header: []const u8) AuthResult {
            zap.debug("AuthenticateUserPass\n", .{});
            const encoded = auth_header[AuthScheme.Basic.str().len..];
            const decoder = std.base64.standard.Decoder;
            var buffer: [0x100]u8 = undefined;
            if (decoder.calcSizeForSlice(encoded)) |decoded_size| {
                if (decoded_size >= buffer.len) {
                    zap.debug(
                        "ERROR: UserPassAuth: decoded_size {d} >= buffer.len {d}\n",
                        .{ decoded_size, buffer.len },
                    );
                    return .AuthFailed;
                }
                const decoded = buffer[0..decoded_size];
                decoder.decode(decoded, encoded) catch |err| {
                    zap.debug(
                        "ERROR: UserPassAuth: unable to decode `{s}`: {any}\n",
                        .{ encoded, err },
                    );
                    return .AuthFailed;
                };
                // we have decoded
                // we can split
                var it = std.mem.split(u8, decoded, ":");
                const user = it.next();
                const pass = it.next();
                if (user == null or pass == null) {
                    zap.debug(
                        "ERROR: UserPassAuth: user {any} or pass {any} is null\n",
                        .{ user, pass },
                    );
                    return .AuthFailed;
                }
                // now, do the lookup
                const actual_pw = self.lookup.*.get(user.?);
                if (actual_pw) |pw| {
                    const ret = std.mem.eql(u8, pass.?, pw);
                    zap.debug(
                        "INFO: UserPassAuth for user `{s}`: `{s}` == pass `{s}` = {}\n",
                        .{ user.?, pw, pass.?, ret },
                    );
                    return if (ret) .AuthOK else .AuthFailed;
                } else {
                    zap.debug(
                        "ERROR: UserPassAuth: user `{s}` not found in map of size {d}!\n",
                        .{ user.?, self.lookup.*.count() },
                    );
                    return .AuthFailed;
                }
            } else |err| {
                // can't calc slice size --> fallthrough to return false
                zap.debug(
                    "ERROR: UserPassAuth: cannot calc slize size for encoded `{s}`: {any} \n",
                    .{ encoded, err },
                );
                return .AuthFailed;
            }
            zap.debug("UNREACHABLE\n", .{});
            return .AuthFailed;
        }

        /// Use this to just look up if the base64-encoded auth_header exists in lookup.
        /// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
        pub fn authenticateToken68(self: *Self, auth_header: []const u8) AuthResult {
            const token = auth_header[AuthScheme.Basic.str().len..];
            return if (self.lookup.*.contains(token)) .AuthOK else .AuthFailed;
        }

        /// dispatch based on kind (.UserPass / .Token689) and try to authenticate based on the header.
        /// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
        pub fn authenticate(self: *Self, auth_header: []const u8) AuthResult {
            zap.debug("AUTHENTICATE\n", .{});
            switch (kind) {
                .UserPass => return self.authenticateUserPass(auth_header),
                .Token68 => return self.authenticateToken68(auth_header),
            }
        }

        /// The zap authentication request handler.
        ///
        /// Tries to extract the authentication header and perform the authentication.
        /// If no authentication header is found, an authorization header is tried.
        pub fn authenticateRequest(self: *Self, r: *const zap.Request) AuthResult {
            zap.debug("AUTHENTICATE REQUEST\n", .{});
            if (extractAuthHeader(.Basic, r)) |auth_header| {
                zap.debug("Authentication Header found!\n", .{});
                return self.authenticate(auth_header);
            } else {
                // try with .Authorization
                if (extractAuthHeader(.Bearer, r)) |auth_header| {
                    zap.debug("Authorization Header found!\n", .{});
                    return self.authenticate(auth_header);
                }
            }
            zap.debug("NO fitting Auth Header found!\n", .{});
            return .AuthFailed;
        }
    };
}

/// HTTP bearer authentication for a single token
/// RFC 6750
/// "Authentication: Bearer TOKEN"
/// `Bearer` is case-sensitive
///   - we don't support form-encoded `access_token` body parameter
///   - we don't support URI query parameter `access_token`
///
/// Errors:
/// HTTP/1.1 401 Unauthorized
/// WWW-Authenticate: Bearer realm="example", error="invalid_token", error_description="..."
pub const BearerSingle = struct {
    allocator: std.mem.Allocator,
    token: []const u8,
    realm: ?[]const u8,

    const Self = @This();

    /// Creates a Single-Token Bearer Authenticator.
    /// Takes a copy of the token.
    /// If realm is provided (not null), a copy is taken call deinit() to clean up.
    pub fn init(allocator: std.mem.Allocator, token: []const u8, realm: ?[]const u8) !Self {
        return .{
            .allocator = allocator,
            .token = try allocator.dupe(u8, token),
            .realm = if (realm) |the_realm| try allocator.dupe(u8, the_realm) else null,
        };
    }

    /// Try to authenticate based on the header.
    /// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
    pub fn authenticate(self: *Self, auth_header: []const u8) AuthResult {
        if (checkAuthHeader(.Bearer, auth_header) == false) {
            return .AuthFailed;
        }
        const token = auth_header[AuthScheme.Bearer.str().len..];
        return if (std.mem.eql(u8, token, self.token)) .AuthOK else .AuthFailed;
    }

    /// The zap authentication request handler.
    ///
    /// Tries to extract the authentication header and perform the authentication.
    pub fn authenticateRequest(self: *Self, r: *const zap.Request) AuthResult {
        if (extractAuthHeader(.Bearer, r)) |auth_header| {
            return self.authenticate(auth_header);
        }
        return .AuthFailed;
    }

    /// Deinits the authenticator.
    pub fn deinit(self: *Self) void {
        if (self.realm) |the_realm| {
            self.allocator.free(the_realm);
        }
        self.allocator.free(self.token);
    }
};

/// HTTP bearer authentication for multiple tokens
/// RFC 6750
/// "Authentication: Bearer TOKEN"
/// `Bearer` is case-sensitive
///   - we don't support form-encoded `access_token` body parameter
///   - we don't support URI query parameter `access_token`
///
/// Errors:
/// HTTP/1.1 401 Unauthorized
/// WWW-Authenticate: Bearer realm="example", error="invalid_token", error_description="..."
pub fn BearerMulti(comptime Lookup: type) type {
    return struct {
        allocator: std.mem.Allocator,
        lookup: *Lookup,
        realm: ?[]const u8,

        const Self = @This();

        /// Creates a Multi Token Bearer Authenticator. `lookup` must implement
        /// `.get([]const u8) -> []const u8` to look up tokens.
        /// If realm is provided (not null), a copy of it is taken -> call deinit() to clean up.
        pub fn init(allocator: std.mem.Allocator, lookup: *Lookup, realm: ?[]const u8) !Self {
            return .{
                .allocator = allocator,
                .lookup = lookup,
                .realm = if (realm) |the_realm| try allocator.dupe(u8, the_realm) else null,
            };
        }

        /// Deinit the authenticator. Only required if a realm was provided at
        /// init() time.
        pub fn deinit(self: *Self) void {
            if (self.realm) |the_realm| {
                self.allocator.free(the_realm);
            }
        }

        /// Try to authenticate based on the header.
        /// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
        pub fn authenticate(self: *Self, auth_header: []const u8) AuthResult {
            if (checkAuthHeader(.Bearer, auth_header) == false) {
                return .AuthFailed;
            }
            const token = auth_header[AuthScheme.Bearer.str().len..];
            return if (self.lookup.*.contains(token)) .AuthOK else .AuthFailed;
        }

        /// The zap authentication request handler.
        ///
        /// Tries to extract the authentication header and perform the authentication.
        pub fn authenticateRequest(self: *Self, r: *const zap.Request) AuthResult {
            if (extractAuthHeader(.Bearer, r)) |auth_header| {
                return self.authenticate(auth_header);
            }
            return .AuthFailed;
        }
    };
}

/// Settings to initialize a UserPassSession authenticator.
pub const UserPassSessionArgs = struct {
    /// username body parameter
    usernameParam: []const u8,
    /// password body parameter
    passwordParam: []const u8,
    /// redirect to this page if auth fails
    loginPage: []const u8,
    /// name of the auth cookie
    cookieName: []const u8,
    /// cookie max age in seconds; 0 -> session cookie
    cookieMaxAge: u8 = 0,
    /// redirect status code, defaults to 302 found
    redirectCode: zap.StatusCode = .found,
};

/// UserPassSession supports the following use case:
///
/// - checks every request: is it going to the login page? -> let the request through.
/// - else:
///   - checks every request for a session token in a cookie
///   - if there is no token, it checks for correct username and password body params
///     - if username and password are present and correct, it will create a session token,
///       create a response cookie containing the token, and carry on with the request
///     - else it will redirect to the login page
///   - if the session token is present and correct: it will let the request through
///   - else: it will redirect to the login page
///
/// Please note the implications of this simple approach: IF YOU REUSE "username"
/// and "password" body params for anything else in your application, then the
/// mechanisms described above will still kick in. For that reason: please know what
/// you're doing.
///
/// See UserPassSessionArgs:
/// - username & password param names can be defined by you
/// - session cookie name and max-age can be defined by you
/// - login page and redirect code (.302) can be defined by you
///
/// Comptime Parameters:
///
/// - `Lookup` must implement .get([]const u8) -> []const u8 for user password retrieval
/// - `lockedPwLookups` : if true, accessing the provided Lookup instance will be protected
///    by a Mutex. You can access the mutex yourself via the `passwordLookupLock`.
///
/// Note: In order to be quick, you can set lockedTokenLookups to false.
///       -> we generate it on init() and leave it static
///       -> there is no way to 100% log out apart from re-starting the server
///       -> because: we send a cookie to the browser that invalidates the session cookie
///       -> another browser program with the page still open would still be able to use
///       -> the session. Which is kindof OK, but not as cool as erasing the token
///       -> on the server side which immediately block all other browsers as well.
pub fn UserPassSession(comptime Lookup: type, comptime lockedPwLookups: bool) type {
    return struct {
        allocator: std.mem.Allocator,
        lookup: *Lookup,
        settings: UserPassSessionArgs,

        // TODO: cookie store per user?
        sessionTokens: SessionTokenMap,
        passwordLookupLock: std.Thread.Mutex = .{},
        tokenLookupLock: std.Thread.Mutex = .{},

        const Self = @This();
        const SessionTokenMap = std.StringHashMap(void);
        const Hash = std.crypto.hash.sha2.Sha256;

        const Token = [Hash.digest_length * 2]u8;

        /// Construct this authenticator. See above and related types for more
        /// information.
        pub fn init(
            allocator: std.mem.Allocator,
            lookup: *Lookup,
            args: UserPassSessionArgs,
        ) !Self {
            const ret: Self = .{
                .allocator = allocator,
                .settings = .{
                    .usernameParam = try allocator.dupe(u8, args.usernameParam),
                    .passwordParam = try allocator.dupe(u8, args.passwordParam),
                    .loginPage = try allocator.dupe(u8, args.loginPage),
                    .cookieName = try allocator.dupe(u8, args.cookieName),
                    .cookieMaxAge = args.cookieMaxAge,
                    .redirectCode = args.redirectCode,
                },
                .lookup = lookup,
                .sessionTokens = SessionTokenMap.init(allocator),
            };

            return ret;
        }

        /// De-init this authenticator.
        pub fn deinit(self: *Self) void {
            self.allocator.free(self.settings.usernameParam);
            self.allocator.free(self.settings.passwordParam);
            self.allocator.free(self.settings.loginPage);
            self.allocator.free(self.settings.cookieName);

            // clean up the session tokens: the key strings are duped
            var key_it = self.sessionTokens.keyIterator();
            while (key_it.next()) |key_ptr| {
                self.allocator.free(key_ptr.*);
            }
            self.sessionTokens.deinit();
        }

        /// Check for session token cookie, remove the token from the valid tokens
        pub fn logout(self: *Self, r: *const zap.Request) void {
            // we  erase the list of valid tokens server-side (later) and set the
            // cookie to "invalid" on the client side.
            if (r.setCookie(.{
                .name = self.settings.cookieName,
                .value = "invalid",
                .max_age_s = -1,
            })) {
                zap.debug("logout ok\n", .{});
            } else |err| {
                zap.debug("logout cookie setting failed: {any}\n", .{err});
            }

            r.parseCookies(false);

            // check for session cookie
            if (r.getCookieStr(self.allocator, self.settings.cookieName, false)) |maybe_cookie| {
                if (maybe_cookie) |cookie| {
                    defer cookie.deinit();
                    self.tokenLookupLock.lock();
                    defer self.tokenLookupLock.unlock();
                    if (self.sessionTokens.getKeyPtr(cookie.str)) |keyPtr| {
                        const keySlice = keyPtr.*;
                        // if cookie is a valid session, remove it!
                        _ = self.sessionTokens.remove(cookie.str);
                        // only now can we let go of the cookie str slice that
                        // was used as the key
                        self.allocator.free(keySlice);
                    }
                }
            } else |err| {
                zap.debug("unreachable: UserPassSession.logout: {any}", .{err});
            }
        }

        fn _internal_authenticateRequest(self: *Self, r: *const zap.Request) AuthResult {
            // if we're requesting the login page, let the request through
            if (r.path) |p| {
                if (std.mem.startsWith(u8, p, self.settings.loginPage)) {
                    return .AuthOK;
                }
            }

            // parse body
            r.parseBody() catch {
                // zap.debug("warning: parseBody() failed in UserPassSession: {any}", .{err});
                // this is not an error in case of e.g. gets with querystrings
            };

            r.parseCookies(false);

            // check for session cookie
            if (r.getCookieStr(self.allocator, self.settings.cookieName, false)) |maybe_cookie| {
                if (maybe_cookie) |cookie| {
                    defer cookie.deinit();
                    // locked or unlocked token lookup
                    self.tokenLookupLock.lock();
                    defer self.tokenLookupLock.unlock();
                    if (self.sessionTokens.contains(cookie.str)) {
                        // cookie is a valid session!
                        zap.debug("Auth: COOKIE IS OK!!!!: {s}\n", .{cookie.str});
                        return .AuthOK;
                    } else {
                        zap.debug("Auth: COOKIE IS BAD!!!!: {s}\n", .{cookie.str});
                        // this is not necessarily a bad thing. it could be a
                        // stale cookie from a previous session. So let's check
                        // if username and password are being sent and correct.
                    }
                }
            } else |err| {
                zap.debug("unreachable: could not check for cookie in UserPassSession: {any}", .{err});
            }

            // get params of username and password
            if (r.getParamStr(self.allocator, self.settings.usernameParam, false)) |maybe_username| {
                if (maybe_username) |*username| {
                    defer username.deinit();
                    if (r.getParamStr(self.allocator, self.settings.passwordParam, false)) |maybe_pw| {
                        if (maybe_pw) |*pw| {
                            defer pw.deinit();

                            // now check
                            const correct_pw_optional = brk: {
                                if (lockedPwLookups) {
                                    self.passwordLookupLock.lock();
                                    defer self.passwordLookupLock.unlock();
                                    break :brk self.lookup.*.get(username.str);
                                } else {
                                    break :brk self.lookup.*.get(username.str);
                                }
                            };
                            if (correct_pw_optional) |correct_pw| {
                                if (std.mem.eql(u8, pw.str, correct_pw)) {
                                    // create session token
                                    if (self.createAndStoreSessionToken(username.str, pw.str)) |token| {
                                        defer self.allocator.free(token);
                                        // now set the cookie header
                                        if (r.setCookie(.{
                                            .name = self.settings.cookieName,
                                            .value = token,
                                            .max_age_s = self.settings.cookieMaxAge,
                                        })) {
                                            return .AuthOK;
                                        } else |err| {
                                            zap.debug("could not set session token: {any}", .{err});
                                        }
                                    } else |err| {
                                        zap.debug("could not create session token: {any}", .{err});
                                    }
                                    // errors with token don't mean the auth itself wasn't OK
                                    return .AuthOK;
                                }
                            }
                        }
                    } else |err| {
                        zap.debug("getParamSt() for password failed in UserPassSession: {any}", .{err});
                        return .AuthFailed;
                    }
                }
            } else |err| {
                zap.debug("getParamSt() for user failed in UserPassSession: {any}", .{err});
                return .AuthFailed;
            }
            return .AuthFailed;
        }

        /// The zap authentication request handler.
        ///
        /// See above for how it works.
        pub fn authenticateRequest(self: *Self, r: *const zap.Request) AuthResult {
            switch (self._internal_authenticateRequest(r)) {
                .AuthOK => {
                    // username and pass are ok -> created token, set header, caller can continue
                    return .AuthOK;
                },
                // this does not happen, just for completeness
                .Handled => return .Handled,
                // auth failed -> redirect
                .AuthFailed => {
                    // we need to redirect and return .Handled
                    self.redirect(r) catch |err| {
                        // we just give up
                        zap.debug("redirect() failed in UserPassSession: {any}", .{err});
                    };
                    return .Handled;
                },
            }
        }

        fn redirect(self: *Self, r: *const zap.Request) !void {
            try r.redirectTo(self.settings.loginPage, self.settings.redirectCode);
        }

        fn createSessionToken(self: *Self, username: []const u8, password: []const u8) ![]const u8 {
            var hasher = Hash.init(.{});
            hasher.update(username);
            hasher.update(password);
            var buf: [16]u8 = undefined;
            const time_nano = std.time.nanoTimestamp();
            const timestampHex = try std.fmt.bufPrint(&buf, "{0x}", .{time_nano});
            hasher.update(timestampHex);

            var digest: [Hash.digest_length]u8 = undefined;
            hasher.final(&digest);
            const token: Token = std.fmt.bytesToHex(digest, .lower);
            const token_str = try self.allocator.dupe(u8, token[0..token.len]);
            return token_str;
        }

        fn createAndStoreSessionToken(self: *Self, username: []const u8, password: []const u8) ![]const u8 {
            const token = try self.createSessionToken(username, password);
            self.tokenLookupLock.lock();
            defer self.tokenLookupLock.unlock();

            if (!self.sessionTokens.contains(token)) {
                try self.sessionTokens.put(try self.allocator.dupe(u8, token), {});
            }
            return token;
        }
    };
}
