// https://github.com/P-H-C/phc-string-format

const std = @import("std");
const fmt = std.fmt;
const mem = std.mem;
const meta = std.meta;
const Writer = std.Io.Writer;

const fields_delimiter = "$";
const fields_delimiter_scalar = '$';
const version_param_name = "v";
const params_delimiter = ",";
const params_delimiter_scalar = ',';
const kv_delimiter = "=";
const kv_delimiter_scalar = '=';

pub const Error = std.crypto.errors.EncodingError || error{NoSpaceLeft};

const B64Decoder = std.base64.standard_no_pad.Decoder;
const B64Encoder = std.base64.standard_no_pad.Encoder;

/// A wrapped binary value whose maximum size is `max_len`.
///
/// This type must be used whenever a binary value is encoded in a PHC-formatted string.
/// This includes `salt`, `hash`, and any other binary parameters such as keys.
///
/// Once initialized, the actual value can be read with the `constSlice()` function.
pub fn BinValue(comptime max_len: usize) type {
    return struct {
        const Self = @This();
        const capacity = max_len;
        const max_encoded_length = B64Encoder.calcSize(max_len);

        buf: [max_len]u8 = undefined,
        len: usize = 0,

        /// Wrap an existing byte slice
        pub fn fromSlice(slice: []const u8) Error!Self {
            if (slice.len > capacity) return Error.NoSpaceLeft;
            var bin_value: Self = undefined;
            @memcpy(bin_value.buf[0..slice.len], slice);
            bin_value.len = slice.len;
            return bin_value;
        }

        /// Return the slice containing the actual value.
        pub fn constSlice(self: *const Self) []const u8 {
            return self.buf[0..self.len];
        }

        fn fromB64(self: *Self, str: []const u8) !void {
            const len = B64Decoder.calcSizeForSlice(str) catch return Error.InvalidEncoding;
            if (len > self.buf.len) return Error.NoSpaceLeft;
            B64Decoder.decode(&self.buf, str) catch return Error.InvalidEncoding;
            self.len = len;
        }

        fn toB64(self: *const Self, buf: []u8) ![]const u8 {
            const value = self.constSlice();
            const len = B64Encoder.calcSize(value.len);
            if (len > buf.len) return Error.NoSpaceLeft;
            return B64Encoder.encode(buf, value);
        }
    };
}

/// Deserialize a PHC-formatted string into a structure `HashResult`.
///
/// Required field in the `HashResult` structure:
///   - `alg_id`: algorithm identifier
/// Optional, special fields:
///   - `alg_version`: algorithm version (unsigned integer)
///   - `salt`: salt
///   - `hash`: output of the hash function
///
/// Other fields will also be deserialized from the function parameters section.
pub fn deserialize(comptime HashResult: type, str: []const u8) Error!HashResult {
    if (@hasField(HashResult, version_param_name)) {
        @compileError("Field name '" ++ version_param_name ++ "'' is reserved for the algorithm version");
    }

    var out = mem.zeroes(HashResult);
    var it = mem.splitScalar(u8, str, fields_delimiter_scalar);
    var set_fields: usize = 0;

    while (true) {
        // Read the algorithm identifier
        if ((it.next() orelse return Error.InvalidEncoding).len != 0) return Error.InvalidEncoding;
        out.alg_id = it.next() orelse return Error.InvalidEncoding;
        set_fields += 1;

        // Read the optional version number
        var field = it.next() orelse break;
        if (kvSplit(field)) |opt_version| {
            if (mem.eql(u8, opt_version.key, version_param_name)) {
                if (@hasField(HashResult, "alg_version")) {
                    const ValueType = switch (@typeInfo(@TypeOf(out.alg_version))) {
                        .optional => |opt| opt.child,
                        else => @TypeOf(out.alg_version),
                    };
                    out.alg_version = fmt.parseUnsigned(
                        ValueType,
                        opt_version.value,
                        10,
                    ) catch return Error.InvalidEncoding;
                    set_fields += 1;
                }
                field = it.next() orelse break;
            }
        } else |_| {}

        // Read optional parameters
        var has_params = false;
        var it_params = mem.splitScalar(u8, field, params_delimiter_scalar);
        while (it_params.next()) |params| {
            const param = kvSplit(params) catch break;
            var found = false;
            inline for (comptime meta.fields(HashResult)) |p| {
                if (mem.eql(u8, p.name, param.key)) {
                    switch (@typeInfo(p.type)) {
                        .int => @field(out, p.name) = fmt.parseUnsigned(
                            p.type,
                            param.value,
                            10,
                        ) catch return Error.InvalidEncoding,
                        .pointer => |ptr| {
                            if (!ptr.is_const) @compileError("Value slice must be constant");
                            @field(out, p.name) = param.value;
                        },
                        .@"struct" => try @field(out, p.name).fromB64(param.value),
                        else => std.debug.panic(
                            "Value for [{s}] must be an integer, a constant slice or a BinValue",
                            .{p.name},
                        ),
                    }
                    set_fields += 1;
                    found = true;
                    break;
                }
            }
            if (!found) return Error.InvalidEncoding; // An unexpected parameter was found in the string
            has_params = true;
        }

        // No separator between an empty parameters set and the salt
        if (has_params) field = it.next() orelse break;

        // Read an optional salt
        if (@hasField(HashResult, "salt")) {
            try out.salt.fromB64(field);
            set_fields += 1;
        } else {
            return Error.InvalidEncoding;
        }

        // Read an optional hash
        field = it.next() orelse break;
        if (@hasField(HashResult, "hash")) {
            try out.hash.fromB64(field);
            set_fields += 1;
        } else {
            return Error.InvalidEncoding;
        }
        break;
    }

    // Check that all the required fields have been set, excluding optional values and parameters
    // with default values
    var expected_fields: usize = 0;
    inline for (comptime meta.fields(HashResult)) |p| {
        if (@typeInfo(p.type) != .optional and p.default_value_ptr == null) {
            expected_fields += 1;
        }
    }
    if (set_fields < expected_fields) return Error.InvalidEncoding;

    return out;
}

/// Serialize parameters into a PHC string.
///
/// Required field for `params`:
///   - `alg_id`: algorithm identifier
/// Optional, special fields:
///   - `alg_version`: algorithm version (unsigned integer)
///   - `salt`: salt
///   - `hash`: output of the hash function
///
/// `params` can also include any additional parameters.
pub fn serialize(params: anytype, str: []u8) Error![]const u8 {
    var w: Writer = .fixed(str);
    serializeTo(params, &w) catch return error.NoSpaceLeft;
    return w.buffered();
}

/// Compute the number of bytes required to serialize `params`
pub fn calcSize(params: anytype) usize {
    var trash: [128]u8 = undefined;
    var d: Writer.Discarding = .init(&trash);
    serializeTo(params, &d.writer) catch unreachable;
    return @intCast(d.fullCount());
}

fn serializeTo(params: anytype, out: *std.Io.Writer) !void {
    const HashResult = @TypeOf(params);

    if (@hasField(HashResult, version_param_name)) {
        @compileError("Field name '" ++ version_param_name ++ "'' is reserved for the algorithm version");
    }

    try out.writeAll(fields_delimiter);
    try out.writeAll(params.alg_id);

    if (@hasField(HashResult, "alg_version")) {
        if (@typeInfo(@TypeOf(params.alg_version)) == .optional) {
            if (params.alg_version) |alg_version| {
                try out.print(
                    "{s}{s}{s}{}",
                    .{ fields_delimiter, version_param_name, kv_delimiter, alg_version },
                );
            }
        } else {
            try out.print(
                "{s}{s}{s}{}",
                .{ fields_delimiter, version_param_name, kv_delimiter, params.alg_version },
            );
        }
    }

    var has_params = false;
    inline for (comptime meta.fields(HashResult)) |p| {
        if (comptime !(mem.eql(u8, p.name, "alg_id") or
            mem.eql(u8, p.name, "alg_version") or
            mem.eql(u8, p.name, "hash") or
            mem.eql(u8, p.name, "salt")))
        {
            const value = @field(params, p.name);
            try out.writeAll(if (has_params) params_delimiter else fields_delimiter);
            if (@typeInfo(p.type) == .@"struct") {
                var buf: [@TypeOf(value).max_encoded_length]u8 = undefined;
                try out.print("{s}{s}{s}", .{ p.name, kv_delimiter, try value.toB64(&buf) });
            } else {
                try out.print(
                    if (@typeInfo(@TypeOf(value)) == .pointer) "{s}{s}{s}" else "{s}{s}{}",
                    .{ p.name, kv_delimiter, value },
                );
            }
            has_params = true;
        }
    }

    var has_salt = false;
    if (@hasField(HashResult, "salt")) {
        var buf: [@TypeOf(params.salt).max_encoded_length]u8 = undefined;
        try out.print("{s}{s}", .{ fields_delimiter, try params.salt.toB64(&buf) });
        has_salt = true;
    }

    if (@hasField(HashResult, "hash")) {
        var buf: [@TypeOf(params.hash).max_encoded_length]u8 = undefined;
        if (!has_salt) try out.writeAll(fields_delimiter);
        try out.print("{s}{s}", .{ fields_delimiter, try params.hash.toB64(&buf) });
    }
}

// Split a `key=value` string into `key` and `value`
fn kvSplit(str: []const u8) !struct { key: []const u8, value: []const u8 } {
    var it = mem.splitScalar(u8, str, kv_delimiter_scalar);
    const key = it.first();
    const value = it.next() orelse return Error.InvalidEncoding;
    return .{ .key = key, .value = value };
}

test "phc format - encoding/decoding" {
    const Input = struct {
        str: []const u8,
        HashResult: type,
    };
    const inputs = [_]Input{
        .{
            .str = "$argon2id$v=19$key=a2V5,m=4096,t=0,p=1$X1NhbHQAAAAAAAAAAAAAAA$bWh++MKN1OiFHKgIWTLvIi1iHicmHH7+Fv3K88ifFfI",
            .HashResult = struct {
                alg_id: []const u8,
                alg_version: u16,
                key: BinValue(16),
                m: usize,
                t: u64,
                p: u32,
                salt: BinValue(16),
                hash: BinValue(32),
            },
        },
        .{
            .str = "$scrypt$v=1$ln=15,r=8,p=1$c2FsdHNhbHQ$dGVzdHBhc3M",
            .HashResult = struct {
                alg_id: []const u8,
                alg_version: ?u30,
                ln: u6,
                r: u30,
                p: u30,
                salt: BinValue(16),
                hash: BinValue(16),
            },
        },
        .{
            .str = "$scrypt",
            .HashResult = struct { alg_id: []const u8 },
        },
        .{ .str = "$scrypt$v=1", .HashResult = struct { alg_id: []const u8, alg_version: u16 } },
        .{
            .str = "$scrypt$ln=15,r=8,p=1",
            .HashResult = struct { alg_id: []const u8, alg_version: ?u30, ln: u6, r: u30, p: u30 },
        },
        .{
            .str = "$scrypt$c2FsdHNhbHQ",
            .HashResult = struct { alg_id: []const u8, salt: BinValue(16) },
        },
        .{
            .str = "$scrypt$v=1$ln=15,r=8,p=1$c2FsdHNhbHQ",
            .HashResult = struct {
                alg_id: []const u8,
                alg_version: u16,
                ln: u6,
                r: u30,
                p: u30,
                salt: BinValue(16),
            },
        },
        .{
            .str = "$scrypt$v=1$ln=15,r=8,p=1",
            .HashResult = struct { alg_id: []const u8, alg_version: ?u30, ln: u6, r: u30, p: u30 },
        },
        .{
            .str = "$scrypt$v=1$c2FsdHNhbHQ$dGVzdHBhc3M",
            .HashResult = struct {
                alg_id: []const u8,
                alg_version: u16,
                salt: BinValue(16),
                hash: BinValue(16),
            },
        },
        .{
            .str = "$scrypt$v=1$c2FsdHNhbHQ",
            .HashResult = struct { alg_id: []const u8, alg_version: u16, salt: BinValue(16) },
        },
        .{
            .str = "$scrypt$c2FsdHNhbHQ$dGVzdHBhc3M",
            .HashResult = struct { alg_id: []const u8, salt: BinValue(16), hash: BinValue(16) },
        },
    };
    inline for (inputs) |input| {
        const v = try deserialize(input.HashResult, input.str);
        var buf: [input.str.len]u8 = undefined;
        const s1 = try serialize(v, &buf);
        try std.testing.expectEqualSlices(u8, input.str, s1);
    }
}

test "phc format - empty input string" {
    const s = "";
    const v = deserialize(struct { alg_id: []const u8 }, s);
    try std.testing.expectError(Error.InvalidEncoding, v);
}

test "phc format - hash without salt" {
    const s = "$scrypt";
    const v = deserialize(struct { alg_id: []const u8, hash: BinValue(16) }, s);
    try std.testing.expectError(Error.InvalidEncoding, v);
}

test "phc format - calcSize" {
    const s = "$scrypt$v=1$ln=15,r=8,p=1$c2FsdHNhbHQ$dGVzdHBhc3M";
    const v = try deserialize(struct {
        alg_id: []const u8,
        alg_version: u16,
        ln: u6,
        r: u30,
        p: u30,
        salt: BinValue(8),
        hash: BinValue(8),
    }, s);
    try std.testing.expectEqual(calcSize(v), s.len);
}
