const std = @import("std");
const mem = std.mem;
const math = std.math;
const sort = std.sort;
const unicode = std.unicode;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const AutoHashMap = std.AutoHashMap;
const StringHashMap = std.StringHashMap;
const MultiArrayList = std.MultiArrayList;
const ArrayListUnmanaged = std.ArrayListUnmanaged;

const User = @import("User.zig");
const Group = @import("Group.zig");
const ErrCtx = @import("ErrCtx.zig");

pub const Corpus = @This();

arena: ArenaAllocator,

// sorted by name, by unicode codepoint
users: MultiArrayList(User),
// sorted by gid
groups: MultiArrayList(Group),

name2user: StringHashMap(u32),
name2group: StringHashMap(u32),
group2users: []const []const u32,
user2groups: []const []const u32,

getgr_bufsize: usize,
getpw_bufsize: usize,

pub fn init(
    baseAllocator: Allocator,
    usersConst: []const User,
    groupsConst: []const Group,
    err: *ErrCtx,
) error{ OutOfMemory, InvalidUtf8, Duplicate, NotFound, TooMany }!Corpus {
    if (usersConst.len >= math.maxInt(u32)) return error.TooMany;
    if (groupsConst.len >= math.maxInt(u32)) return error.TooMany;

    var users = MultiArrayList(User){};
    var groups = MultiArrayList(Group){};
    var getgr_bufsize: usize = 0;
    var getpw_bufsize: usize = 0;

    var arena = ArenaAllocator.init(baseAllocator);
    var allocator = arena.allocator();
    errdefer arena.deinit();

    const NameIdx = struct {
        name: []const u8,
        idx: usize,
    };

    const GidIdx = struct {
        gid: u32,
        idx: usize,
    };

    const Compare = struct {
        fn name(_: void, a: NameIdx, b: NameIdx) bool {
            var utf8_a = (unicode.Utf8View.init(a.name) catch unreachable).iterator();
            var utf8_b = (unicode.Utf8View.init(b.name) catch unreachable).iterator();
            while (utf8_a.nextCodepoint()) |codepoint_a| {
                if (utf8_b.nextCodepoint()) |codepoint_b| {
                    if (codepoint_a == codepoint_b) {
                        continue;
                    } else return codepoint_a < codepoint_b;
                }

                // a is a prefix of b. It is thus shorter.
                return true;
            }
            // b is a prefix of a
            return false;
        }
        fn gid(_: void, a: GidIdx, b: GidIdx) bool {
            return a.gid < b.gid;
        }
    };

    // TODO: replace with MultiArrayList sort when
    // https://github.com/ziglang/zig/issues/11117 is done. As of writing it
    // was quite a bit slower.
    {
        var name_idx = try baseAllocator.alloc(NameIdx, usersConst.len);
        defer baseAllocator.free(name_idx);
        for (usersConst, 0..) |user, i| name_idx[i] =
            NameIdx{ .name = user.name, .idx = i };
        sort.pdq(NameIdx, name_idx, {}, Compare.name);

        try users.ensureTotalCapacity(allocator, usersConst.len);
        for (name_idx) |entry| {
            const user = try usersConst[entry.idx].clone(allocator);
            users.appendAssumeCapacity(user);
            getpw_bufsize = @max(getpw_bufsize, user.strlenZ());
        }
    }

    {
        var gid_idx = try baseAllocator.alloc(GidIdx, groupsConst.len);
        defer baseAllocator.free(gid_idx);
        for (groupsConst, 0..) |group, i|
            gid_idx[i] = GidIdx{ .gid = group.gid, .idx = i };
        sort.pdq(GidIdx, gid_idx, {}, Compare.gid);

        try groups.ensureTotalCapacity(allocator, groupsConst.len);
        for (gid_idx) |entry| {
            const group = try groupsConst[entry.idx].clone(allocator);
            groups.appendAssumeCapacity(group);
            getgr_bufsize = @max(getgr_bufsize, group.strlenZ());
        }
    }

    // verify whatever comes to cmph are unique: user names
    var name2user = StringHashMap(u32).init(allocator);
    for (users.items(.name), 0..) |name, i| {
        var result = try name2user.getOrPut(name);
        if (result.found_existing)
            return error.Duplicate;
        result.value_ptr.* = @intCast(u32, i);
    }
    // verify whatever comes to cmph are unique: group names
    var name2group = StringHashMap(u32).init(allocator);
    for (groups.items(.name), 0..) |name, i| {
        var result = try name2group.getOrPut(name);
        if (result.found_existing)
            return error.Duplicate;
        result.value_ptr.* = @intCast(u32, i);
    }

    // verify whatever comes to cmph are unique: gids
    {
        const gids = groups.items(.gid);
        var last_gid = gids[0];
        for (gids[1..]) |gid| {
            if (gid == last_gid)
                return err.returnf("duplicate gid {d}", .{gid}, error.Duplicate);
            last_gid = gid;
        }
    }

    // verify whatever comes to cmph are unique: uids
    {
        var uid_map = AutoHashMap(u32, void).init(allocator);
        defer uid_map.deinit();
        for (users.items(.uid)) |uid| {
            const result = try uid_map.getOrPut(uid);
            if (result.found_existing)
                return err.returnf("duplicate uid {d}", .{uid}, error.Duplicate);
        }
    }

    var group2users = try allocator.alloc([]u32, groups.len);

    // uses baseAllocator, because it will be freed before
    // returning from this function. This keeps the arena clean.
    var user2groups = try baseAllocator.alloc(ArrayListUnmanaged(u32), users.len);
    defer baseAllocator.free(user2groups);
    @memset(user2groups, ArrayListUnmanaged(u32){});

    for (groups.items(.members), groups.items(.name), 0..) |groupmembers, name, i| {
        var members = try allocator.alloc(u32, groupmembers.len);
        members.len = 0;

        for (groupmembers) |member_name| {
            if (name2user.get(member_name)) |user_idx| {
                // If user is in a group of their primary gid, the right thing
                // to do is to skip the membersip. This is what classical tools
                // do (look at your etc/passwd), but not what Ours Truly nss
                // implementation does. However, since there is at least one
                // known implementation that does not do this, push the
                // responsibility to the validator (and keep the code
                // commented).
                // if (users.items(.gid)[user_idx] == groups.items(.gid)[i])
                //     continue;
                members.len += 1;
                members[members.len - 1] = user_idx;
                try user2groups[user_idx].append(allocator, @intCast(u32, i));
            } else {
                return err.returnf(
                    "user '{s}' not found, member of group '{s}'",
                    .{ member_name, name },
                    error.NotFound,
                );
            }
        }

        group2users[i] = members;
    }

    for (group2users) |*groupusers|
        sort.pdq(u32, groupusers.*, {}, comptime sort.asc(u32));

    var user2groups_final = try allocator.alloc([]const u32, users.len);
    user2groups_final.len = users.len;
    for (user2groups, user2groups_final) |*usergroups, *user2groups_final_i| {
        sort.pdq(u32, usergroups.items, {}, comptime sort.asc(u32));
        user2groups_final_i.* = try usergroups.toOwnedSlice(allocator);
    }

    return Corpus{
        .arena = arena,
        .users = users,
        .groups = groups,
        .name2user = name2user,
        .name2group = name2group,
        .group2users = group2users,
        .user2groups = user2groups_final,
        .getgr_bufsize = getgr_bufsize,
        .getpw_bufsize = getpw_bufsize,
    };
}

pub fn deinit(self: *Corpus) void {
    self.arena.deinit();
    self.* = undefined;
}

fn testUser(name: []const u8) User {
    var result = mem.zeroes(User);
    result.name = name;
    return result;
}

const testing = std.testing;
const someMembers = @import("Group.zig").someMembers;

pub fn testCorpus(allocator: Allocator) !Corpus {
    const users = [_]User{ User{
        .uid = 0,
        .gid = 0,
        .name = "root",
        .gecos = "",
        .home = "/root",
        .shell = "/bin/bash",
    }, User{
        .uid = 128,
        .gid = 128,
        .name = "vidmantas",
        .gecos = "Vidmantas Kaminskas",
        .home = "/home/vidmantas",
        .shell = "/bin/bash",
    }, User{
        .uid = 1000,
        .gid = math.maxInt(u32),
        .name = "Name" ** 8,
        .gecos = "Gecos" ** 51,
        .home = "/Hom" ** 16,
        .shell = "/She.Lll" ** 8,
    }, User{
        .uid = 100000,
        .gid = 1002,
        .name = "svc-bar",
        .gecos = "",
        .home = "/",
        .shell = "/",
    }, User{
        .uid = 65534,
        .gid = 65534,
        .name = "nobody",
        .gecos = "nobody",
        .home = "/nonexistent",
        .shell = "/usr/sbin/nologin",
    } };

    var group0 = try Group.init(allocator, 0, "root", &[_][]const u8{});
    var group1 = try Group.init(allocator, 128, "vidmantas", &[_][]const u8{});
    var group2 = try Group.init(
        allocator,
        9999,
        "all",
        &[_][]const u8{ "svc-bar", "Name" ** 8, "vidmantas", "root" },
    );
    var group3 = try Group.init(
        allocator,
        100000,
        "service-group",
        &[_][]const u8{ "vidmantas", "root" },
    );
    defer group0.deinit(allocator);
    defer group1.deinit(allocator);
    defer group2.deinit(allocator);
    defer group3.deinit(allocator);

    const groups = [_]Group{ group0, group1, group2, group3 };

    var errc = ErrCtx{};
    const result = try Corpus.init(allocator, users[0..], groups[0..], &errc);
    try testing.expectEqualStrings("", errc.unwrap().constSlice());
    return result;
}

test "corpus smoke test" {
    var corpus = try testCorpus(testing.allocator);
    defer corpus.deinit();

    const name_name = 0;
    const nobody = 1;
    const root = 2;
    const svc_bar = 3;
    const vidmantas = 4;

    const usernames = corpus.users.items(.name);
    try testing.expectEqualStrings(usernames[name_name], "Name" ** 8);
    try testing.expectEqualStrings(usernames[nobody], "nobody");
    try testing.expectEqualStrings(usernames[root], "root");
    try testing.expectEqualStrings(usernames[svc_bar], "svc-bar");
    try testing.expectEqualStrings(usernames[vidmantas], "vidmantas");

    const g_root = 0;
    const g_vidmantas = 1;
    const g_all = 2;
    const g_service_account = 3;

    const groupnames = corpus.groups.items(.name);
    try testing.expectEqualStrings(groupnames[g_root], "root");
    try testing.expectEqualStrings(groupnames[g_service_account], "service-group");
    try testing.expectEqualStrings(groupnames[g_vidmantas], "vidmantas");
    try testing.expectEqualStrings(groupnames[g_all], "all");

    try testing.expectEqual(corpus.name2user.get("404"), null);
    try testing.expectEqual(corpus.name2user.get("vidmantas").?, vidmantas);
    try testing.expectEqual(corpus.name2group.get("404"), null);
    try testing.expectEqual(corpus.name2group.get("vidmantas").?, g_vidmantas);

    const membersOfAll = corpus.group2users[g_all];
    try testing.expectEqual(membersOfAll[0], name_name);
    try testing.expectEqual(membersOfAll[1], root);
    try testing.expectEqual(membersOfAll[2], svc_bar);
    try testing.expectEqual(membersOfAll[3], vidmantas);

    const groupsOfVidmantas = corpus.user2groups[vidmantas];
    try testing.expectEqual(groupsOfVidmantas[0], g_all);
    try testing.expectEqual(groupsOfVidmantas[1], g_service_account);
}
