// zig fmt: off
const Root = @import("root.zig");
const Store = @import("./store.zig");
const std = @import("std");
const json_utils = @import("json_utils.zig");
const errors = @import("./errors.zig");
const stb_image = @import("./stb_image/root.zig");

const FetchError = errors.FetchError;
const assert = std.debug.assert;
const json = std.json;
const Allocator = std.mem.Allocator;
const AuthToken = Root.AuthToken;
const log = std.log.scoped(.api);
const ServerURL = std.BoundedArray(u8, 256);

const Item = @import("./schemas/item.zig");
const Craft = @import("./schemas/craft.zig");
const Status = @import("./schemas/status.zig");
const Character = @import("./schemas/character.zig");
const Monster = @import("./schemas/monster.zig");
const Resource = @import("./schemas/resource.zig");
const Position = @import("./schemas/position.zig");
const Map = @import("./schemas/map.zig");
const Image = Store.Image;

const Server = @This();

pub const RateLimit = struct {
    pub const Category = enum {
        account_creation,
        token,
        data,
        actions
    };

    pub const Timespan = struct {
        counter: u32 = 0,
        limit: u32,
        timer_ms: u64 = 0,
    };

    seconds: ?Timespan = null,
    minutes: ?Timespan = null,
    hours: ?Timespan = null,

    last_update_at_ms: i64 = 0,

    pub fn init(
        now: i64,
        limit_per_hour: ?u32,
        limit_per_minute: ?u32,
        limit_per_second: ?u32
    ) RateLimit {
        var seconds: ?Timespan = null;
        if (limit_per_second) |limit| {
            seconds = Timespan{ .limit = limit };
        }

        var minutes: ?Timespan = null;
        if (limit_per_minute) |limit| {
            minutes = Timespan{ .limit = limit };
        }

        var hours: ?Timespan = null;
        if (limit_per_hour) |limit| {
            hours = Timespan{ .limit = limit };
        }

        return RateLimit{
            .seconds = seconds,
            .minutes = minutes,
            .hours = hours,
            .last_update_at_ms = now
        };
    }

    pub fn update_timers(self: *RateLimit) void {
        const now = std.time.milliTimestamp();
        const time_passed_ms = now - self.last_update_at_ms;
        assert(time_passed_ms >= 0);

        inline for (.{
            .{ &self.seconds, std.time.ms_per_s    },
            .{ &self.minutes, std.time.ms_per_min  },
            .{ &self.hours  , std.time.ms_per_hour },
        }) |tuple| {
            const maybe_timespan = tuple[0];
            const timespan_size = tuple[1];

            if (maybe_timespan.*) |*timespan| {
                timespan.timer_ms += @intCast(time_passed_ms);

                const ms_per_request = @divFloor(timespan_size, timespan.limit);
                const requests_passed: u32 = @intCast(@divFloor(timespan.timer_ms, ms_per_request));
                timespan.counter -= @min(timespan.counter, requests_passed);
                timespan.timer_ms = @mod(timespan.timer_ms, ms_per_request);
            }
        }

        self.last_update_at_ms = now;
    }

    pub fn increment_counters(self: *RateLimit) void {
        inline for (.{
            &self.hours,
            &self.minutes,
            &self.seconds,
        }) |maybe_timespan| {
            if (maybe_timespan.*) |*timespan| {
                timespan.counter += 1;
            }
        }
    }
};

const RateLimits = std.EnumArray(RateLimit.Category, RateLimit);

const max_response_size = 1024 * 1024 * 16;

// TODO: Figure out a way to more accuretly pick a good 'self.fetch_buffer' size
fetch_buffer: []u8,
client: std.http.Client,
ratelimits: RateLimits,

server_url: ServerURL,
server_uri: std.Uri,

token: ?AuthToken = null,
store: *Store,

pub fn init(allocator: Allocator, store: *Store) !Server {
    const response_buffer = try allocator.alloc(u8, max_response_size);
    errdefer allocator.free(response_buffer);

    const now = std.time.milliTimestamp();

    // Limits gotten from https://docs.artifactsmmo.com/api_guide/rate_limits
    var ratelimits = RateLimits.initFill(RateLimit{});
    ratelimits.set(.account_creation, RateLimit.init(now, 50, null, null));
    ratelimits.set(.token, RateLimit.init(now, 50, null, null));
    ratelimits.set(.data, RateLimit.init(now, 7200, 200, 16));
    ratelimits.set(.actions, RateLimit.init(now, 7200, 200, 5));

    return Server{
        .client = .{ .allocator = allocator },
        .server_url = try ServerURL.fromSlice(Root.api_url),
        .server_uri = try std.Uri.parse(Root.api_url),
        .store = store,
        .fetch_buffer = response_buffer,
        .ratelimits = ratelimits
    };
}

pub fn deinit(self: *Server) void {
    const allocator = self.client.allocator;
    self.client.deinit();
    allocator.free(self.fetch_buffer);
}

pub fn setURL(self: *Server, url: []const u8) !void {
    const server_url = try ServerURL.fromSlice(url);

    self.server_uri = try std.Uri.parse(server_url);
    self.server_url = server_url;
}

pub fn setToken(self: *Server, token: ?[]const u8) !void {
    if (token) |t| {
        self.token = try AuthToken.fromSlice(t);
    } else {
        self.token = null;
    }
}

const FetchJsonOptions = struct {
    const QueryValue = struct {
        key: []const u8,
        value: []const u8,
    };

    method: std.http.Method,
    path: []const u8,
    ratelimit: RateLimit.Category,
    payload: ?[]const u8 = null,

    query: ?[]const QueryValue = null,

    page: ?u64 = null,
    page_size: ?u64 = null,

    /// When enabled, will to iterate over all available pages
    paginated: bool = false,
};

pub const FetchJsonResult = struct {
    status: std.http.Status,
    body: ?json.Value = null,
};

fn formatQueryValues(buffer: *std.ArrayList(u8), pairs: []const FetchJsonOptions.QueryValue) ![]u8 {
    buffer.clearRetainingCapacity();

    for (0.., pairs) |i, pair| {
        if (i > 0) {
            try buffer.appendSlice("&");
        }

        try buffer.appendSlice(pair.key);
        try buffer.appendSlice("=");
        try buffer.appendSlice(pair.value);
    }

    return buffer.items;
}

fn maxIntBufferSize(comptime T: type) usize {
    const max_int_size = std.fmt.count("{}", .{ std.math.maxInt(T) });
    const min_int_size = std.fmt.count("{}", .{ std.math.minInt(T) });
    return @max(max_int_size, min_int_size);
}

fn fetch(self: *Server, ratelimit: RateLimit.Category, options: std.http.Client.FetchOptions) !std.http.Client.FetchResult {
    log.debug("+---- fetch -----", .{  });
    log.debug("| endpoint {} {}", .{ options.method orelse .GET, options.location.uri });
    if (options.payload) |payload| {
        log.debug("| payload {s}", .{ payload });
    }

    const result = try self.client.fetch(options);

    var ratelimit_obj = self.ratelimits.getPtr(ratelimit);
    ratelimit_obj.increment_counters();

    log.debug("| status {}", .{ result.status });

    if (ratelimit != .token) {
        const body = switch (options.response_storage) {
            .static => |body| body.items,
            .dynamic => |body| body.items,
            .ignore => null
        };

        const png_header = [_]u8{ 137, 80, 78, 71, 13, 10, 26, 10 };
        if (body) |b| {
            if (std.mem.startsWith(u8, b, &png_header)) {
                log.debug("| response <PNG Image>", .{ });
            } else {
                log.debug("| response {s}", .{ b });
            }
        }
    }

    return result;
}

fn fetchJson(self: *Server, options: FetchJsonOptions) FetchError!FetchJsonResult {
    var uri = self.server_uri;
    uri.path = .{ .raw = options.path };

    var fbs = std.heap.FixedBufferAllocator.init(self.fetch_buffer);
    const allocator = fbs.allocator();

    var result_status: std.http.Status = .ok;
    var result_body: ?json.Value = null;

    var current_page: u64 = options.page orelse 1;
    var total_pages: u64 = 1;
    var fetch_results = std.ArrayList(json.Value).init(allocator);

    var authorization_header: std.http.Client.Request.Headers.Value = .default;
    {
        // + 7 for "Bearer "
        var authorization_header_buffer: [Root.max_auth_token_size + 7]u8 = undefined;

        if (self.token) |token_buff| {
            const token = token_buff.slice();
            authorization_header = .{ 
                .override = std.fmt.bufPrint(&authorization_header_buffer, "Bearer {s}", .{token}) catch return FetchError.OutOfMemory
            };
        }
    }

    var query_buffer = std.ArrayList(u8).init(allocator);

    const max_query_parametrs = 2 + @max(
        0,
        5, // self.getItems
    );

    while (true) : (current_page += 1) {
        // NOTE: The limit on this array was hand picked. If the limit is hit, it should be increased.
        var query = std.BoundedArray(FetchJsonOptions.QueryValue, max_query_parametrs).init(0) catch unreachable; 
        if (options.query) |additional_query| {
            query.appendSlice(additional_query) catch return error.OutOfMemory;
        }

        const page_buffer_size = comptime maxIntBufferSize(@TypeOf(current_page));
        var page_buffer: [page_buffer_size]u8 = undefined;

        const page_size_buffer_size = comptime maxIntBufferSize(@TypeOf(options.page_size.?));
        var page_size_buffer: [page_size_buffer_size]u8 = undefined;

        if (options.paginated) {
            const page_str = std.fmt.bufPrint(&page_buffer, "{}", .{ current_page }) catch unreachable;
            query.append(.{ .key = "page", .value = page_str }) catch return error.OutOfMemory;

            if (options.page_size) |page_size| {
                const page_size_str = std.fmt.bufPrint(&page_size_buffer, "{}", .{ page_size }) catch unreachable;
                query.append(.{ .key = "size", .value = page_size_str }) catch return error.OutOfMemory;
            }
        }

        if (query.len > 0) {
            uri.query = .{ .raw = try formatQueryValues(&query_buffer, query.slice()) };
        }

        var response_storage = std.ArrayList(u8).init(allocator);
        var opts = std.http.Client.FetchOptions{
            .method = options.method,
            .location = .{ .uri = uri },
            .payload = options.payload,
            .response_storage = .{ .dynamic = &response_storage },
        };
        opts.headers.authorization = authorization_header;

        const result = self.fetch(options.ratelimit, opts) catch return FetchError.RequestFailed;
        const response_body = response_storage.items;

        var ratelimit = self.ratelimits.getPtr(options.ratelimit);
        ratelimit.increment_counters();

        const status = @intFromEnum(result.status);
        if (status == errors.NotAuthenticated.code.?) {
            return FetchError.NotAuthenticated;
        } else if (status == errors.ServerUnavailable.code.?) {
            return FetchError.ServerUnavailable;
        } else if (status == errors.InvalidPayload.code.?) {
            return FetchError.InvalidPayload;
        } else if (status == errors.TooManyRequests.code.?) {
            return FetchError.TooManyRequests;
        } else if (status == errors.FatalError.code.?) {
            return FetchError.FatalError;
        } else if (result.status != .ok) {
            return FetchJsonResult{ .status = result.status };
        }

        const parsed = json.parseFromSliceLeaky(json.Value, allocator, response_body, .{ .allocate = .alloc_if_needed }) catch return FetchError.ParseFailed;
        if (parsed != json.Value.object) {
            return FetchError.ParseFailed;
        }

        result_status = result.status;

        if (options.paginated) {
            const total_i64 = json_utils.getInteger(parsed.object, "total") orelse return FetchError.ParseFailed;
            if (total_i64 < 0) return FetchError.ParseFailed;
            try fetch_results.ensureTotalCapacity(@intCast(total_i64));

            const total_pages_i64 = json_utils.getInteger(parsed.object, "pages") orelse return FetchError.ParseFailed;
            if (total_pages_i64 < 0) return FetchError.ParseFailed;
            total_pages = @intCast(total_pages_i64);

            const page_results = json_utils.getArray(parsed.object, "data") orelse return FetchError.ParseFailed;
            fetch_results.appendSlice(page_results.items) catch return FetchError.OutOfMemory;

            if (current_page >= total_pages) break;
        } else {
            result_body = parsed.object.get("data");
            break;
        }
    }

    if (options.paginated) {
        result_body = json.Value{ .array = fetch_results };
    }

    return FetchJsonResult{
        .status = result_status,
        .body = result_body
    };
}

fn handleFetchError(
    status: std.http.Status,
    Error: type,
    parseError: ?fn (status: std.http.Status) ?Error,
) ?Error {
    if (status != .ok) {
        if (Error != FetchError) {
            if (parseError == null) {
                @compileError("`parseError` must be defined, if `Error` is not `FetchError`");
            }

            if (parseError.?(status)) |error_value| {
                return error_value;
            }
        } else {
            if (parseError != null) {
                @compileError("`parseError` must be null");
            }
        }
    }

    return null;
}

fn fetchOptionalObject(
    self: *Server,
    Error: type,
    parseError: ?fn (status: std.http.Status) ?Error,
    Result: type,
    parseObject: fn (store: *Store, obj: json.ObjectMap) anyerror!Result,
    fetchOptions: FetchJsonOptions,
) Error!?Result {
    const result = try self.fetchJson(fetchOptions);

    if (handleFetchError(result.status, Error, parseError)) |error_value| {
        return error_value;
    }
    if (result.status == .not_found) {
        return null;
    }
    if (result.status != .ok) {
        return FetchError.RequestFailed;
    }

    if (result.body == null) {
        return FetchError.ParseFailed;
    }

    const body = json_utils.asObject(result.body.?) orelse return FetchError.ParseFailed;
    return parseObject(self.store, body) catch return FetchError.ParseFailed;
}

fn fetchObject(
    self: *Server,
    Error: type,
    parseError: ?fn (status: std.http.Status) ?Error,
    Object: type,
    parseObject: fn (store: *Store, obj: json.ObjectMap) anyerror!Object,
    fetchOptions: FetchJsonOptions
) Error!Object {
    const result = try self.fetchOptionalObject(Error, parseError, Object, parseObject, fetchOptions);
    return result orelse return FetchError.RequestFailed;
}

fn fetchOptionalArray(
    self: *Server,
    allocator: Allocator,
    Error: type,
    parseError: ?fn (status: std.http.Status) ?Error,
    Object: type,
    parseObject: fn (store: *Store, obj: json.ObjectMap) anyerror!Object,
    fetchOptions: FetchJsonOptions
) Error!?std.ArrayList(Object) {
    const result = try self.fetchJson(fetchOptions);

    if (handleFetchError(result.status, Error, parseError)) |error_value| {
        return error_value;
    }
    if (result.status == .not_found) {
        return null;
    }
    if (result.status != .ok) {
        return FetchError.RequestFailed;
    }

    if (result.body == null) {
        return FetchError.ParseFailed;
    }

    var array = std.ArrayList(Object).init(allocator);
    errdefer array.deinit();

    const result_data = json_utils.asArray(result.body.?) orelse return FetchError.ParseFailed;
    for (result_data.items) |result_item| {
        const item_obj = json_utils.asObject(result_item) orelse return FetchError.ParseFailed;

        const parsed_item = parseObject(self.store, item_obj) catch return FetchError.ParseFailed;
        array.append(parsed_item) catch return FetchError.OutOfMemory;
    }

    return array;
}

fn fetchArray(
    self: *Server,
    allocator: Allocator,
    Error: type,
    parseError: ?fn (status: std.http.Status) ?Error,
    Object: type,
    parseObject: fn (store: *Store, body: json.ObjectMap) anyerror!Object,
    fetchOptions: FetchJsonOptions
) Error!std.ArrayList(Object) {
    const result = try self.fetchOptionalArray(allocator, Error, parseError, Object, parseObject, fetchOptions);
    return result orelse return FetchError.RequestFailed;
}

fn prefetch(self: *Server, allocator: std.mem.Allocator) !void {
    // TODO: Create a version of `getResources`, `getMonsters`, `getItems`, etc..
    // which don't need an allocator to be passed.
    // This is for cases when you only care that everything will be saved into the store.

    const resources = try self.getResources(allocator, .{});
    defer resources.deinit();

    const maps: std.ArrayList(Map) = try self.getMaps(allocator, .{});
    defer maps.deinit();

    const monsters = try self.getMonsters(allocator, .{});
    defer monsters.deinit();

    const items = try self.getItems(allocator, .{});
    defer items.deinit();

    for (maps.items) |map| {
        const skin: []const u8 = map.skin.slice();
        if (self.store.images.getId(.map, skin) == null) {
            _ = try self.getImage(.map, skin);
        }
    }
}

pub fn prefetchCached(self: *Server, allocator: std.mem.Allocator, absolute_cache_path: []const u8) !void {
    const status: Status = try self.getStatus();
    const version = status.version.slice();

    if (std.fs.openFileAbsolute(absolute_cache_path, .{})) |file| {
        defer file.close();

        if (self.store.load(allocator, version, file.reader())) {
            return; // Saved store was loaded successfully
        } else |_| {}
    } else |_| {}

    try self.prefetch(allocator);

    const file = try std.fs.createFileAbsolute(absolute_cache_path, .{});
    defer file.close();
    try self.store.save(version, file.writer());
}

// ------------------------- Endpoints ------------------------

// https://api.artifactsmmo.com/docs/#/operations/get_status__get
pub fn getStatus(self: *Server) FetchError!Status {
    return try self.fetchObject(
        FetchError,
        null,
        Status,
        Status.parse,
        .{ .method = .GET, .ratelimit = .data, .path = "/" }
    );
}

// https://api.artifactsmmo.com/docs/#/operations/get_my_characters_my_characters_get
pub fn getMyCharacters(self: *Server, allocator: Allocator) FetchError!std.ArrayList(Store.Id) {
    return try self.fetchArray(
        allocator,
        FetchError,
        null,
        Store.Id,
        Character.parseAndAppend,
        .{ .method = .GET, .path = "/my/characters", .ratelimit = .data }
    );
}

// https://api.artifactsmmo.com/docs/#/operations/get_character_characters__name__get
pub fn getCharacter(self: *Server, name: []const u8) FetchError!?Store.Id {
    const path_buff_size = comptime blk: {
        var count = 0;
        count += 12; // "/characters/"
        count += Character.max_name_size;
        break :blk count;
    };

    var path_buff: [path_buff_size]u8 = undefined;
    const path = std.fmt.bufPrint(&path_buff, "/characters/{s}", .{name}) catch return FetchError.InvalidPayload;

    return try self.fetchOptionalObject(
        FetchError,
        null,
        Store.Id,
        Character.parseAndAppend,
        .{ .method = .GET, .path = path, .ratelimit = .data }
    );
}

// https://api.artifactsmmo.com/docs/#/operations/create_character_characters_create_post
pub fn createCharacter(self: *Server, name: []const u8, skin: Character.Skin) errors.CreateCharacterError!Store.Id {
    var payload_buffer: [128]u8 = undefined;
    const payload = std.fmt.bufPrint(&payload_buffer, "{{ \"name\":\"{s}\",\"skin\":\"{s}\" }}", .{ name, skin.toString() }) catch return FetchError.InvalidPayload;

    return try self.fetchObject(
        errors.CreateCharacterError,
        errors.parseCreateCharacterError,
        Store.Id,
        Character.parseAndAppend,
        .{ .method = .POST, .path = "/characters/create", .ratelimit = .actions, .payload = payload }
    );
}

// https://api.artifactsmmo.com/docs/#/operations/delete_character_characters_delete_post
pub fn deleteCharacter(self: *Server, name: []const u8) errors.DeleteCharacterError!void {
    var payload_buffer: [64]u8 = undefined;
    const payload = std.fmt.bufPrint(&payload_buffer, "{{ \"name\":\"{s}\" }}", .{ name }) catch return FetchError.InvalidPayload;

    _ = try self.fetchObject(
        errors.DeleteCharacterError,
        errors.parseDeleteCharacterError,
        Character,
        Character.parse,
        .{ .method = .POST, .path = "/characters/delete", .ratelimit = .actions, .payload = payload }
    );

    if (self.store.characters.getId(name)) |id| {
        _ = self.store.characters.remove(id);
    }
}

// https://api.artifactsmmo.com/docs/#/operations/get_item_items__code__get
pub fn getItem(self: *Server, code: []const u8) FetchError!?Store.Id {
    const path_buff_size = comptime blk: {
        var count = 0;
        count += 7; // "/items/"
        count += Item.max_code_size;
        break :blk count;
    };

    var path_buff: [path_buff_size]u8 = undefined;
    const path = std.fmt.bufPrint(&path_buff, "/items/{s}", .{code}) catch return FetchError.InvalidPayload;

    return try self.fetchOptionalObject(
        FetchError,
        null,
        Store.Id,
        Item.parseAndAppend,
        .{ .method = .GET, .path = path, .ratelimit = .data }
    );
}

pub const ItemOptions = struct {
    craft_material: ?[]const u8 = null,
    craft_skill: ?Craft.Skill = null,
    min_level: ?u64 = null,
    max_level: ?u64 = null,
    name: ?[]const u8 = null,
    type: ?Item.Type = null,
};

// https://api.artifactsmmo.com/docs/#/operations/get_all_items_items_get
pub fn getItems(self: *Server, allocator: Allocator, opts: ItemOptions) FetchError!std.ArrayList(Store.Id) {
    const min_level_buffer_size = comptime maxIntBufferSize(@TypeOf(opts.min_level.?));
    var min_level_buffer: [min_level_buffer_size]u8 = undefined;

    const max_level_buffer_size = comptime maxIntBufferSize(@TypeOf(opts.max_level.?));
    var max_level_buffer: [max_level_buffer_size]u8 = undefined;

    var query = std.BoundedArray(FetchJsonOptions.QueryValue, 5).init(0) catch unreachable; 

    if (opts.craft_material) |craft_material| {
        query.append(.{ .key = "craft_material", .value = craft_material }) catch unreachable;
    }
    if (opts.min_level) |min_level| {
        const min_level_str = std.fmt.bufPrint(&min_level_buffer, "{}", .{ min_level }) catch unreachable;
        query.append(.{ .key = "min_level", .value = min_level_str }) catch unreachable;
    }
    if (opts.max_level) |max_level| {
        const max_level_str = std.fmt.bufPrint(&max_level_buffer, "{}", .{ max_level }) catch unreachable;
        query.append(.{ .key = "max_level", .value = max_level_str }) catch unreachable;
    }
    if (opts.name) |name| {
        query.append(.{ .key = "name", .value = name }) catch unreachable;
    }
    if (opts.type) |item_type| {
        query.append(.{ .key = "type", .value = Item.Type.toString(item_type) }) catch unreachable;
    }

    return try self.fetchArray(
        allocator,
        FetchError,
        null,
        Store.Id,
        Item.parseAndAppend,
        .{
            .method = .GET,
            .path = "/items",
            .ratelimit = .data,
            .paginated = true,
            .page_size = 100,
            .query = query.slice()
        }
    );
}

// https://api.artifactsmmo.com/docs/#/operations/get_monster_monsters__code__get
pub fn getMonster(self: *Server, code: []const u8) FetchError!?Store.Id {
    const path_buff_size = comptime blk: {
        var count = 0;
        count += 10; // "/monsters/"
        count += Monster.max_code_size;
        break :blk count;
    };

    var path_buff: [path_buff_size]u8 = undefined;
    const path = std.fmt.bufPrint(&path_buff, "/monsters/{s}", .{code}) catch return FetchError.InvalidPayload;

    return try self.fetchOptionalObject(
        FetchError,
        null,
        Store.Id,
        Monster.parseAndAppend,
        .{ .method = .GET, .path = path, .ratelimit = .data }
    );
}

pub const MonsterOptions = struct {
    drop: ?[]const u8 = null,
    max_level: ?u64 = null,
    min_level: ?u64 = null,
};

// https://api.artifactsmmo.com/docs/#/operations/get_all_monsters_monsters_get
pub fn getMonsters(self: *Server, allocator: Allocator, opts: MonsterOptions) FetchError!std.ArrayList(Store.Id) {
    const min_level_buffer_size = comptime maxIntBufferSize(@TypeOf(opts.min_level.?));
    var min_level_buffer: [min_level_buffer_size]u8 = undefined;

    const max_level_buffer_size = comptime maxIntBufferSize(@TypeOf(opts.max_level.?));
    var max_level_buffer: [max_level_buffer_size]u8 = undefined;

    var query = std.BoundedArray(FetchJsonOptions.QueryValue, 3).init(0) catch unreachable; 

    if (opts.drop) |drop| {
        query.append(.{ .key = "drop", .value = drop }) catch unreachable;
    }
    if (opts.min_level) |min_level| {
        const min_level_str = std.fmt.bufPrint(&min_level_buffer, "{}", .{ min_level }) catch unreachable;
        query.append(.{ .key = "min_level", .value = min_level_str }) catch unreachable;
    }
    if (opts.max_level) |max_level| {
        const max_level_str = std.fmt.bufPrint(&max_level_buffer, "{}", .{ max_level }) catch unreachable;
        query.append(.{ .key = "max_level", .value = max_level_str }) catch unreachable;
    }

    return try self.fetchArray(
        allocator,
        FetchError,
        null,
        Store.Id,
        Monster.parseAndAppend,
        .{
            .method = .GET,
            .path = "/monsters",
            .ratelimit = .data,
            .paginated = true,
            .page_size = 100,
            .query = query.slice()
        }
    );
}

// https://api.artifactsmmo.com/docs/#/operations/get_resource_resources__code__get
pub fn getResource(self: *Server, code: []const u8) FetchError!?Store.Id {
    const path_buff_size = comptime blk: {
        var count = 0;
        count += 11; // "/resources/"
        count += Resource.max_code_size;
        break :blk count;
    };

    var path_buff: [path_buff_size]u8 = undefined;
    const path = std.fmt.bufPrint(&path_buff, "/resources/{s}", .{code}) catch return FetchError.InvalidPayload;

    return try self.fetchOptionalObject(
        FetchError,
        null,
        Store.Id,
        Resource.parseAndAppend,
        .{ .method = .GET, .path = path, .ratelimit = .data }
    );
}

pub const ResourceOptions = struct {
    drop: ?[]const u8 = null,
    max_level: ?u64 = null,
    min_level: ?u64 = null,
    skill: ?Resource.Skill = null,
};

// https://api.artifactsmmo.com/docs/#/operations/get_all_resources_resources_get
pub fn getResources(self: *Server, allocator: Allocator, opts: ResourceOptions) FetchError!std.ArrayList(Store.Id) {
    const min_level_buffer_size = comptime maxIntBufferSize(@TypeOf(opts.min_level.?));
    var min_level_buffer: [min_level_buffer_size]u8 = undefined;

    const max_level_buffer_size = comptime maxIntBufferSize(@TypeOf(opts.max_level.?));
    var max_level_buffer: [max_level_buffer_size]u8 = undefined;

    var query = std.BoundedArray(FetchJsonOptions.QueryValue, 4).init(0) catch unreachable; 

    if (opts.drop) |drop| {
        query.append(.{ .key = "drop", .value = drop }) catch unreachable;
    }
    if (opts.min_level) |min_level| {
        const min_level_str = std.fmt.bufPrint(&min_level_buffer, "{}", .{ min_level }) catch unreachable;
        query.append(.{ .key = "min_level", .value = min_level_str }) catch unreachable;
    }
    if (opts.max_level) |max_level| {
        const max_level_str = std.fmt.bufPrint(&max_level_buffer, "{}", .{ max_level }) catch unreachable;
        query.append(.{ .key = "max_level", .value = max_level_str }) catch unreachable;
    }
    if (opts.skill) |skill| {
        query.append(.{ .key = "skill", .value = skill.toString() }) catch unreachable;
    }

    return try self.fetchArray(
        allocator,
        FetchError,
        null,
        Store.Id,
        Resource.parseAndAppend,
        .{
            .method = .GET,
            .path = "/resources",
            .ratelimit = .data,
            .paginated = true,
            .page_size = 100,
            .query = query.slice()
        }
    );
}

// https://api.artifactsmmo.com/docs/#/operations/get_map_maps__x___y__get
pub fn getMap(self: *Server, position: Position) FetchError!?Map {
    const path_buff_size = comptime blk: {
        var count = 0;
        count += 6; // "/maps/"
        count += maxIntBufferSize(@TypeOf(position.x));
        count += 1; // "/"
        count += maxIntBufferSize(@TypeOf(position.y));
        break :blk count;
    };

    var path_buff: [path_buff_size]u8 = undefined;
    const path = std.fmt.bufPrint(&path_buff, "/maps/{}/{}", .{ position.x, position.y }) catch return FetchError.InvalidPayload;

    const result = try self.fetchOptionalObject(
        FetchError,
        null,
        Position,
        Map.parseAndAppend,
        .{ .method = .GET, .path = path, .ratelimit = .data }
    );

    if (result != null) {
        return self.store.getMap(result.?).?.*;
    } else {
        return null;
    }
}

pub const MapOptions = struct {
    code: ?[]const u8 = null,
    type: ?Map.Content.Type = null,
};

// https://api.artifactsmmo.com/docs/#/operations/get_all_maps_maps_get
pub fn getMaps(self: *Server, allocator: Allocator, opts: MapOptions) FetchError!std.ArrayList(Map) {
    var query = std.BoundedArray(FetchJsonOptions.QueryValue, 2).init(0) catch unreachable; 

    if (opts.code) |code| {
        query.append(.{ .key = "content_code", .value = code }) catch unreachable;
    }
    if (opts.type) |content_type| {
        query.append(.{ .key = "content_type", .value = content_type.toString() }) catch unreachable;
    }

    return try self.fetchArray(
        allocator,
        FetchError,
        null,
        Map,
        Map.parseAndAppendObject,
        .{
            .method = .GET,
            .path = "/maps",
            .ratelimit = .data,
            .paginated = true,
            .page_size = 100,
            .query = query.slice()
        }
    );
}

// https://docs.artifactsmmo.com/resources/images
pub fn getImage(self: *Server, category: Image.Category, code: []const u8) FetchError!Store.Id {
    const category_path = switch (category) {
        .character => "characters",
        .item => "items",
        .monster => "monsters",
        .map => "maps",
        .resource => "resources",
        .effect => "effects",
    };

    const path_buff_size = comptime blk: {
        var count = 0;
        count += 8; // "/images/"
        count += 10; // For the longest path "characters"
        count += 1; // "/"
        count += Image.max_code_size;
        count += 4; // ".png"
        break :blk count;
    };

    var path_buff: [path_buff_size]u8 = undefined;
    const path = std.fmt.bufPrint(&path_buff, "/images/{s}/{s}.png", .{ category_path, code }) catch return FetchError.InvalidPayload;

    var uri = Root.images_uri;
    uri.path = .{ .raw = path };

    var fbs = std.heap.FixedBufferAllocator.init(self.fetch_buffer);
    const allocator = fbs.allocator();

    var response_storage = std.ArrayList(u8).init(allocator);
    const opts = std.http.Client.FetchOptions{
        .method = .GET,
        .location = .{ .uri = uri },
        .response_storage = .{ .dynamic = &response_storage },
    };

    const result = self.fetch(.data, opts) catch return FetchError.RequestFailed;
    const response_body = response_storage.items;

    if (result.status != .ok) {
        return FetchError.RequestFailed;
    }

    const image = stb_image.load(response_body) catch return FetchError.FatalError;
    defer image.deinit(); 

    const image_id = self.store.images.append(category, code, image.width, image.height) catch return FetchError.OutOfMemory;
    const stored_rgba = self.store.images.getRGBA(image_id).?;
    @memcpy(stored_rgba, image.rgba);

    return image_id;
}

// https://api.artifactsmmo.com/docs/#/operations/generate_token_token_post
pub fn generateToken(self: *Server, username: []const u8, password: []const u8) !AuthToken {
    const base64_encoder = std.base64.standard.Encoder;

    var credentials_buffer: [256]u8 = undefined;
    const credentials = try std.fmt.bufPrint(&credentials_buffer, "{s}:{s}", .{username, password});

    const max_encoded_size = comptime base64_encoder.calcSize(credentials_buffer.len);
    const max_authorization_header_size = comptime blk: {
        var sum: usize = 0;
        sum += 6; // "Basic "
        sum += max_encoded_size;
        break :blk sum;
    };
    var authorization_header_buffer: [max_authorization_header_size]u8 = undefined;
    @memcpy(authorization_header_buffer[0..6], "Basic ");

    const encoded_credentials = base64_encoder.encode(authorization_header_buffer[6..], credentials);
    const authorization_header = authorization_header_buffer[0..(6 + encoded_credentials.len)];

    var fbs = std.heap.FixedBufferAllocator.init(self.fetch_buffer);
    const allocator = fbs.allocator();

    var uri = self.server_uri;
    uri.path = .{ .raw = "/token" };

    var response_storage = std.ArrayList(u8).init(allocator);
    var opts = std.http.Client.FetchOptions{
        .method = .POST,
        .location = .{ .uri = uri },
        .response_storage = .{ .dynamic = &response_storage },
    };
    opts.headers.authorization = .{ .override = authorization_header };

    const result = self.fetch(.token, opts) catch return FetchError.RequestFailed;
    const response_body = response_storage.items;

    if (result.status != .ok) {
        return FetchError.RequestFailed;
    }

    const parsed = try json.parseFromSliceLeaky(json.Value, allocator, response_body, .{ .allocate = .alloc_if_needed });
    if (parsed != json.Value.object) {
        return FetchError.ParseFailed;
    }

    const token = try json_utils.getStringRequired(parsed.object, "token");

    return try AuthToken.fromSlice(token);
}
