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

const Allocator = std.mem.Allocator;

const testing = std.testing;
const ascii = std.ascii;
const assert = std.debug.assert;

pub const HeaderList = std.ArrayListUnmanaged(Field);
pub const HeaderIndexList = std.ArrayListUnmanaged(usize);
pub const HeaderIndex = std.HashMapUnmanaged([]const u8, HeaderIndexList, CaseInsensitiveStringContext, std.hash_map.default_max_load_percentage);

pub const CaseInsensitiveStringContext = struct {
    pub fn hash(self: @This(), s: []const u8) u64 {
        _ = self;
        var buf: [64]u8 = undefined;
        var i: u8 = 0;

        var h = std.hash.Wyhash.init(0);
        while (i < s.len) : (i += 64) {
            const left = @min(64, s.len - i);
            const ret = ascii.lowerString(buf[0..], s[i..][0..left]);
            h.update(ret);
        }

        return h.final();
    }

    pub fn eql(self: @This(), a: []const u8, b: []const u8) bool {
        _ = self;
        return ascii.eqlIgnoreCase(a, b);
    }
};

pub const Field = struct {
    name: []const u8,
    value: []const u8,

    pub fn modify(entry: *Field, allocator: Allocator, new_value: []const u8) !void {
        if (entry.value.len <= new_value.len) {
            std.mem.copy(u8, @constCast(entry.value), new_value);
        } else {
            allocator.free(entry.value);

            entry.value = try allocator.dupe(u8, new_value);
        }
    }

    fn lessThan(ctx: void, a: Field, b: Field) bool {
        _ = ctx;
        if (a.name.ptr == b.name.ptr) return false;

        return ascii.lessThanIgnoreCase(a.name, b.name);
    }
};

pub const Headers = struct {
    allocator: Allocator,
    list: HeaderList = .{},
    index: HeaderIndex = .{},

    /// When this is false, names and values will not be duplicated.
    /// Use with caution.
    owned: bool = true,

    pub fn init(allocator: Allocator) Headers {
        return .{ .allocator = allocator };
    }

    pub fn deinit(headers: *Headers) void {
        headers.deallocateIndexListsAndFields();
        headers.index.deinit(headers.allocator);
        headers.list.deinit(headers.allocator);

        headers.* = undefined;
    }

    /// Appends a header to the list. Both name and value are copied.
    pub fn append(headers: *Headers, name: []const u8, value: []const u8) !void {
        const n = headers.list.items.len;

        const value_duped = if (headers.owned) try headers.allocator.dupe(u8, value) else value;
        errdefer if (headers.owned) headers.allocator.free(value_duped);

        var entry = Field{ .name = undefined, .value = value_duped };

        if (headers.index.getEntry(name)) |kv| {
            entry.name = kv.key_ptr.*;
            try kv.value_ptr.append(headers.allocator, n);
        } else {
            const name_duped = if (headers.owned) try headers.allocator.dupe(u8, name) else name;
            errdefer if (headers.owned) headers.allocator.free(name_duped);

            entry.name = name_duped;

            var new_index = try HeaderIndexList.initCapacity(headers.allocator, 1);
            errdefer new_index.deinit(headers.allocator);

            new_index.appendAssumeCapacity(n);
            try headers.index.put(headers.allocator, name_duped, new_index);
        }

        try headers.list.append(headers.allocator, entry);
    }

    pub fn contains(headers: Headers, name: []const u8) bool {
        return headers.index.contains(name);
    }

    pub fn delete(headers: *Headers, name: []const u8) bool {
        if (headers.index.fetchRemove(name)) |kv| {
            var index = kv.value;

            // iterate backwards

            var i = index.items.len;
            while (i > 0) {
                i -= 1;
                const data_index = index.items[i];
                const removed = headers.list.orderedRemove(data_index);

                assert(ascii.eqlIgnoreCase(removed.name, name)); // ensure the index hasn't been corrupted

                if (headers.owned) headers.allocator.free(removed.value);
            }

            if (headers.owned) headers.allocator.free(kv.key);
            index.deinit(headers.allocator);
            headers.rebuildIndex();

            return true;
        } else {
            return false;
        }
    }

    /// Returns the index of the first occurrence of a header with the given name.
    pub fn firstIndexOf(headers: Headers, name: []const u8) ?usize {
        const index = headers.index.get(name) orelse return null;

        return index.items[0];
    }

    /// Returns a list of indices containing headers with the given name.
    pub fn getIndices(headers: Headers, name: []const u8) ?[]const usize {
        const index = headers.index.get(name) orelse return null;

        return index.items;
    }

    /// Returns the entry of the first occurrence of a header with the given name.
    pub fn getFirstEntry(headers: Headers, name: []const u8) ?Field {
        const first_index = headers.firstIndexOf(name) orelse return null;

        return headers.list.items[first_index];
    }

    /// Returns a slice containing each header with the given name.
    /// The caller owns the returned slice, but NOT the values in the slice.
    pub fn getEntries(headers: Headers, allocator: Allocator, name: []const u8) !?[]const Field {
        const indices = headers.getIndices(name) orelse return null;

        const buf = try allocator.alloc(Field, indices.len);
        for (indices, 0..) |idx, n| {
            buf[n] = headers.list.items[idx];
        }

        return buf;
    }

    /// Returns the value in the entry of the first occurrence of a header with the given name.
    pub fn getFirstValue(headers: Headers, name: []const u8) ?[]const u8 {
        const first_index = headers.firstIndexOf(name) orelse return null;

        return headers.list.items[first_index].value;
    }

    /// Returns a slice containing the value of each header with the given name.
    /// The caller owns the returned slice, but NOT the values in the slice.
    pub fn getValues(headers: Headers, allocator: Allocator, name: []const u8) !?[]const []const u8 {
        const indices = headers.getIndices(name) orelse return null;

        const buf = try allocator.alloc([]const u8, indices.len);
        for (indices, 0..) |idx, n| {
            buf[n] = headers.list.items[idx].value;
        }

        return buf;
    }

    fn rebuildIndex(headers: *Headers) void {
        // clear out the indexes

        var it = headers.index.iterator();
        while (it.next()) |entry| {
            entry.value_ptr.shrinkRetainingCapacity(0);
        }

        // fill up indexes again; we know capacity is fine from before

        for (headers.list.items, 0..) |entry, i| {
            headers.index.getEntry(entry.name).?.value_ptr.appendAssumeCapacity(i);
        }
    }

    /// Sorts the headers in lexicographical order.
    pub fn sort(headers: *Headers) void {
        std.sort.sort(Field, headers.list.items, {}, Field.lessThan);
        headers.rebuildIndex();
    }

    /// Writes the headers to the given stream.
    pub fn format(
        headers: Headers,
        comptime fmt: []const u8,
        options: std.fmt.FormatOptions,
        out_stream: anytype,
    ) !void {
        _ = fmt;
        _ = options;

        for (headers.list.items) |entry| {
            if (entry.value.len == 0) continue;

            try out_stream.writeAll(entry.name);
            try out_stream.writeAll(": ");
            try out_stream.writeAll(entry.value);
            try out_stream.writeAll("\r\n");
        }
    }

    /// Writes all of the headers with the given name to the given stream, separated by commas.
    ///
    /// This is useful for headers like `Set-Cookie` which can have multiple values. RFC 9110, Section 5.2
    pub fn formatCommaSeparated(
        headers: Headers,
        name: []const u8,
        out_stream: anytype,
    ) !void {
        const indices = headers.getIndices(name) orelse return;

        try out_stream.writeAll(name);
        try out_stream.writeAll(": ");

        for (indices, 0..) |idx, n| {
            if (n != 0) try out_stream.writeAll(", ");
            try out_stream.writeAll(headers.list.items[idx].value);
        }

        try out_stream.writeAll("\r\n");
    }

    /// Frees all `HeaderIndexList`s within `index`
    /// Frees names and values of all fields if they are owned.
    fn deallocateIndexListsAndFields(headers: *Headers) void {
        var it = headers.index.iterator();
        while (it.next()) |entry| {
            entry.value_ptr.deinit(headers.allocator);

            if (headers.owned) headers.allocator.free(entry.key_ptr.*);
        }

        if (headers.owned) {
            for (headers.list.items) |entry| {
                headers.allocator.free(entry.value);
            }
        }
    }

    /// Clears and frees the underlying data structures.
    /// Frees names and values if they are owned.
    pub fn clearAndFree(headers: *Headers) void {
        headers.deallocateIndexListsAndFields();
        headers.index.clearAndFree(headers.allocator);
        headers.list.clearAndFree(headers.allocator);
    }

    /// Clears the underlying data structures while retaining their capacities.
    /// Frees names and values if they are owned.
    pub fn clearRetainingCapacity(headers: *Headers) void {
        headers.deallocateIndexListsAndFields();
        headers.index.clearRetainingCapacity();
        headers.list.clearRetainingCapacity();
    }
};

test "Headers.append" {
    var h = Headers{ .allocator = std.testing.allocator };
    defer h.deinit();

    try h.append("foo", "bar");
    try h.append("hello", "world");

    try testing.expect(h.contains("Foo"));
    try testing.expect(!h.contains("Bar"));
}

test "Headers.delete" {
    var h = Headers{ .allocator = std.testing.allocator };
    defer h.deinit();

    try h.append("foo", "bar");
    try h.append("hello", "world");

    try testing.expect(h.contains("Foo"));

    _ = h.delete("Foo");

    try testing.expect(!h.contains("foo"));
}

test "Headers consistency" {
    var h = Headers{ .allocator = std.testing.allocator };
    defer h.deinit();

    try h.append("foo", "bar");
    try h.append("hello", "world");
    _ = h.delete("Foo");

    try h.append("foo", "bar");
    try h.append("bar", "world");
    try h.append("foo", "baz");
    try h.append("baz", "hello");

    try testing.expectEqual(@as(?usize, 0), h.firstIndexOf("hello"));
    try testing.expectEqual(@as(?usize, 1), h.firstIndexOf("foo"));
    try testing.expectEqual(@as(?usize, 2), h.firstIndexOf("bar"));
    try testing.expectEqual(@as(?usize, 4), h.firstIndexOf("baz"));
    try testing.expectEqual(@as(?usize, null), h.firstIndexOf("pog"));

    try testing.expectEqualSlices(usize, &[_]usize{0}, h.getIndices("hello").?);
    try testing.expectEqualSlices(usize, &[_]usize{ 1, 3 }, h.getIndices("foo").?);
    try testing.expectEqualSlices(usize, &[_]usize{2}, h.getIndices("bar").?);
    try testing.expectEqualSlices(usize, &[_]usize{4}, h.getIndices("baz").?);
    try testing.expectEqual(@as(?[]const usize, null), h.getIndices("pog"));

    try testing.expectEqualStrings("world", h.getFirstEntry("hello").?.value);
    try testing.expectEqualStrings("bar", h.getFirstEntry("foo").?.value);
    try testing.expectEqualStrings("world", h.getFirstEntry("bar").?.value);
    try testing.expectEqualStrings("hello", h.getFirstEntry("baz").?.value);

    const hello_entries = (try h.getEntries(testing.allocator, "hello")).?;
    defer testing.allocator.free(hello_entries);
    try testing.expectEqualDeep(@as([]const Field, &[_]Field{
        .{ .name = "hello", .value = "world" },
    }), hello_entries);

    const foo_entries = (try h.getEntries(testing.allocator, "foo")).?;
    defer testing.allocator.free(foo_entries);
    try testing.expectEqualDeep(@as([]const Field, &[_]Field{
        .{ .name = "foo", .value = "bar" },
        .{ .name = "foo", .value = "baz" },
    }), foo_entries);

    const bar_entries = (try h.getEntries(testing.allocator, "bar")).?;
    defer testing.allocator.free(bar_entries);
    try testing.expectEqualDeep(@as([]const Field, &[_]Field{
        .{ .name = "bar", .value = "world" },
    }), bar_entries);

    const baz_entries = (try h.getEntries(testing.allocator, "baz")).?;
    defer testing.allocator.free(baz_entries);
    try testing.expectEqualDeep(@as([]const Field, &[_]Field{
        .{ .name = "baz", .value = "hello" },
    }), baz_entries);

    const pog_entries = (try h.getEntries(testing.allocator, "pog"));
    try testing.expectEqual(@as(?[]const Field, null), pog_entries);

    try testing.expectEqualStrings("world", h.getFirstValue("hello").?);
    try testing.expectEqualStrings("bar", h.getFirstValue("foo").?);
    try testing.expectEqualStrings("world", h.getFirstValue("bar").?);
    try testing.expectEqualStrings("hello", h.getFirstValue("baz").?);
    try testing.expectEqual(@as(?[]const u8, null), h.getFirstValue("pog"));

    const hello_values = (try h.getValues(testing.allocator, "hello")).?;
    defer testing.allocator.free(hello_values);
    try testing.expectEqualDeep(@as([]const []const u8, &[_][]const u8{"world"}), hello_values);

    const foo_values = (try h.getValues(testing.allocator, "foo")).?;
    defer testing.allocator.free(foo_values);
    try testing.expectEqualDeep(@as([]const []const u8, &[_][]const u8{ "bar", "baz" }), foo_values);

    const bar_values = (try h.getValues(testing.allocator, "bar")).?;
    defer testing.allocator.free(bar_values);
    try testing.expectEqualDeep(@as([]const []const u8, &[_][]const u8{"world"}), bar_values);

    const baz_values = (try h.getValues(testing.allocator, "baz")).?;
    defer testing.allocator.free(baz_values);
    try testing.expectEqualDeep(@as([]const []const u8, &[_][]const u8{"hello"}), baz_values);

    const pog_values = (try h.getValues(testing.allocator, "pog"));
    try testing.expectEqual(@as(?[]const []const u8, null), pog_values);

    h.sort();

    try testing.expectEqualSlices(usize, &[_]usize{0}, h.getIndices("bar").?);
    try testing.expectEqualSlices(usize, &[_]usize{1}, h.getIndices("baz").?);
    try testing.expectEqualSlices(usize, &[_]usize{ 2, 3 }, h.getIndices("foo").?);
    try testing.expectEqualSlices(usize, &[_]usize{4}, h.getIndices("hello").?);

    const formatted_values = try std.fmt.allocPrint(testing.allocator, "{}", .{h});
    defer testing.allocator.free(formatted_values);

    try testing.expectEqualStrings("bar: world\r\nbaz: hello\r\nfoo: bar\r\nfoo: baz\r\nhello: world\r\n", formatted_values);

    var buf: [128]u8 = undefined;
    var fbs = std.io.fixedBufferStream(&buf);
    const writer = fbs.writer();

    try h.formatCommaSeparated("foo", writer);
    try testing.expectEqualStrings("foo: bar, baz\r\n", fbs.getWritten());
}

test "Headers.clearRetainingCapacity and clearAndFree" {
    var h = Headers.init(std.testing.allocator);
    defer h.deinit();

    h.clearRetainingCapacity();

    try h.append("foo", "bar");
    try h.append("bar", "world");
    try h.append("foo", "baz");
    try h.append("baz", "hello");
    try testing.expectEqual(@as(usize, 4), h.list.items.len);
    try testing.expectEqual(@as(usize, 3), h.index.count());
    const list_capacity = h.list.capacity;
    const index_capacity = h.index.capacity();

    h.clearRetainingCapacity();
    try testing.expectEqual(@as(usize, 0), h.list.items.len);
    try testing.expectEqual(@as(usize, 0), h.index.count());
    try testing.expectEqual(list_capacity, h.list.capacity);
    try testing.expectEqual(index_capacity, h.index.capacity());

    try h.append("foo", "bar");
    try h.append("bar", "world");
    try h.append("foo", "baz");
    try h.append("baz", "hello");
    try testing.expectEqual(@as(usize, 4), h.list.items.len);
    try testing.expectEqual(@as(usize, 3), h.index.count());
    // Capacity should still be the same since we shouldn't have needed to grow

    // when adding back the same fields

    try testing.expectEqual(list_capacity, h.list.capacity);
    try testing.expectEqual(index_capacity, h.index.capacity());

    h.clearAndFree();
    try testing.expectEqual(@as(usize, 0), h.list.items.len);
    try testing.expectEqual(@as(usize, 0), h.index.count());
    try testing.expectEqual(@as(usize, 0), h.list.capacity);
    try testing.expectEqual(@as(usize, 0), h.index.capacity());
}