//! zhttpd is a very basic http server written in Zig, by ZachIR. It is single
//! threaded, and does not yet support every error code, nor does it support
//! TLS/SSL, but it does display web pages.

// Copyright (C) 2024 ZachIR
//
// This program is free software; you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation; either version 2 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program; if not, write to the Free Software Foundation, Inc., 51
// Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

const std = @import("std");

/// zhttpd relies on mime in addition to std, which is included as made by
/// andrewrk at https://github.com/andrewrk/mime.git
const mime = @import("mime/mime.zig");

const Address = std.net.Address;
const ArrayList = std.ArrayList;
const debug = std.debug;
const fmt = std.fmt;
const heap = std.heap;
const http = std.http;
const fs = std.fs;
const log = std.log.scoped(.server);
const mem = std.mem;
const bufferedReader = std.io.bufferedReader;
const getcwd = std.os.getcwd;

/// The server has a hardcoded address and port of 127.0.0.1:8080
/// The path requested for a file has the limit of fs.MAX_PATH_BYTES, which is
/// the maxmimum length for a file path in the OS
/// The maximum file size is 1 << 21, aka (1 * 2^20), which is 2 MB.
const server_addr = "127.0.0.1";
const server_port = 8080;
const max_path_bytes = fs.MAX_PATH_BYTES;
const buffer_limit = 1 << 21;

/// readFiles() reads the file to a provided buffer, and returns the number of
/// bytes read.
/// readFiles() can fail from fmt.allocPrint(), fs.cwd().openFile(),
/// file.stat(), and file.readAll()
/// fmt.allocPrint() will return an AllocPrintError
/// fs.cwd().openFile() will return a File.OpenError
/// file.stat() will return a StatError
/// file.readAll() will return a ReadError
fn readFiles(target: []const u8, buffer: []u8) !usize {
    const whole_buffer = max_path_bytes * 2;
    var fba_buffer: [whole_buffer]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&fba_buffer);
    const allocator = fba.allocator();

    var file_path = try allocator.alloc(u8, max_path_bytes);
    defer allocator.free(file_path);
    var cwd_buffer = [_]u8{0} ** max_path_bytes;
    const cwd = try getcwd(&cwd_buffer);
    file_path = try fmt.allocPrint(allocator, "{s}{s}", .{ cwd, target });
    log.info("Loading file {s}...", .{file_path});
    // Determine if the requested file exists
    const file = try fs.cwd().openFile(file_path, .{});
    defer file.close();
    var stat = try file.stat();
    switch (stat.kind) {
        .directory => {
            if (mem.endsWith(u8, target, "/")) {
                file_path = try fmt.allocPrint(allocator, "{s}index.html", .{target});
            } else {
                file_path = try fmt.allocPrint(allocator, "{s}/index.html", .{target});
            }
            return readFiles(file_path, buffer);
        },
        .file => {
            return try file.readAll(buffer);
        },
        else => {
            return 0;
        },
    }
}

/// handleRequest() handles the requests from the server and, if necessary,
/// calls readFiles to read requested files.
/// handleRequest() can fail from response.headers.append(), response.do(),
/// response.writeAll(), response.finish(), and readFiles()
/// response.headers.append() does not define what error types it will return
/// response.do() does not define what error types it will return
/// response.writeAll() will return a WriteError
/// response.finish() will return a FinishError
/// readFiles() does not define what error types it will return
/// handleRequest handles the following status codes:
/// - 200 OK
/// - 403 Forbidden
/// - 404 Not Found
/// - 413 Payload Too Large
/// - 414 URI Too Long
fn handleRequest(response: *http.Server.Response) !void {
    // Log the request details
    log.info("{s} {s} {s}", .{ @tagName(response.request.method), @tagName(response.request.version), response.request.target });

    // Create a []u8 to read up to buffer_limit characters
    var read = [_]u8{0} ** buffer_limit;

    // Set "connection" header to "keep-alive" if present in request headers
    if (response.request.headers.contains("connection")) {
        try response.headers.append("connection", "keep-alive");
    }

    const size = readFiles(response.request.target, &read) catch |err| {
        switch (err) {
            error.AccessDenied => {
                response.status = .forbidden;
            },
            error.FileNotFound => {
                response.status = .not_found;
            },
            error.OutOfMemory => {
                response.status = .uri_too_long;
            },
            else => {
                return err;
            },
        }
        response.transfer_encoding = .{ .content_length = 0 };
        try response.do();
        return;
    };
    if (size >= buffer_limit) {
        response.status = .payload_too_large;
        response.transfer_encoding = .{ .content_length = 0 };
        try response.do();
        return;
    } else if (size > 0) {
        // Get the file extension, and set the content-type header if it exists
        // (using mime.zig)
        // To do this, we iterate through response.request.target in reverse
        // looking for a '/' or a '.'
        // a '/' indicates a directory, so there is no extension
        // a '.' indicates a file extension
        var i: usize = response.request.target.len;
        var mime_type: ?mime.Type = undefined;
        if (mem.endsWith(u8, response.request.target, "/")) {
            log.warn("Dir requested, returning index.html!", .{});
            try response.headers.append("content-type", "text/html");
        } else {
            while (i > 0) {
                i -= 1;
                switch (response.request.target[i]) {
                    '/' => {
                        i = 0;
                        break;
                    },
                    '.' => {
                        break;
                    },
                    else => {
                        continue;
                    },
                }
            }
            if (i <= 0) {
                log.warn("No extension detected!", .{});
                if (mem.indexOf(u8, &read, "<html")) |_| {
                    try response.headers.append("content-type", "text/html");
                } else {
                    try response.headers.append("content-type", "text/plain");
                }
            } else {
                log.info("Extension {s} detected!", .{response.request.target[i..]});
                mime_type = mime.extension_map.get(response.request.target[i..]);
                if (mime_type) |mime_val| {
                    try response.headers.append("content-type", @tagName(mime_val));
                } else {
                    try response.headers.append("content-type", "text/plain");
                }
            }
        }
        // Create a []u8 that is exactly the intended size and no larger
        const body = read[0..size];
        log.info("{}", .{body.len});
        // Check if the request target contains "?chunked"
        if (mem.indexOf(u8, response.request.target, "?chunked") != null) {
            response.transfer_encoding = .chunked;
        } else {
            response.transfer_encoding = .{ .content_length = size };
        }
        // Transmit the response
        try response.do();
        if (response.request.method != .HEAD) {
            try response.writeAll(body);
            try response.finish();
        }
    } else {
        // If the file was not found, return error 404
        response.status = .not_found;
        response.transfer_encoding = .{ .content_length = 0 };
        try response.do();
    }
}

/// runServer() accepts inputs from the server, and passes the requests on to
/// handleRequest().
/// runServer() can fail from server.accept(), response.wait(), and
/// handleRequest().
/// server.accept() will return an AcceptError
/// resonse.wait() will return a WaitError
/// runServer() handles the following error codes:
/// - 500 (Internal Server Error)
fn runServer(server: *http.Server) !void {
    var fba_buffer: [buffer_limit]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&fba_buffer);
    const allocator = fba.allocator();

    outer: while (true) {
        // Accept incoming connection
        var response = try server.accept(.{
            .allocator = allocator,
        });
        defer response.deinit();

        while (response.reset() != .closing) {
            // Handle errors during request processing
            response.wait() catch |err| switch (err) {
                error.HttpHeadersInvalid => continue :outer,
                error.EndOfStream => continue,
                else => return err,
            };

            // Process the request
            handleRequest(&response) catch |err| {
                response.status = .internal_server_error;
                response.transfer_encoding = .{ .content_length = 0 };
                try response.do();
                return err;
            };
        }
    }
}

pub fn printInfo(stderr: fs.File.Writer) !void {
    try stderr.print("zhttpd version 0.1.0, Copyright (C) 2024 ZachIR\n", .{});
    try stderr.print("zhttpd comes with ABSOLUTELY NO WARRANTY. This is ", .{});
    try stderr.print("free software, and you are welcome to ", .{});
    try stderr.print("redistribute it under certain conditions; see the ", .{});
    try stderr.print("included LICENSE for more details.\n", .{});
}

/// main() initializes the server, parses the IP and port, and begins
/// runServer().
/// main() can fail exit from server.listen() and runServer(), which do not
/// specify the error types they can return.
pub fn main() !void {
    var fba_buffer: [@sizeOf(http.Server)]u8 = undefined;
    var fba = heap.FixedBufferAllocator.init(&fba_buffer);
    const allocator = fba.allocator();

    //const stdout = std.io.getStdOut().writer();
    const stderr = std.io.getStdErr().writer();

    try printInfo(stderr);

    // Initialize the server
    var server = http.Server.init(allocator, .{ .reuse_address = true });
    defer server.deinit();

    // Log the server address and port
    try stderr.print("Server is running at {s}:{d}\n", .{ server_addr, server_port });

    // Parse the server address
    const address = Address.parseIp(server_addr, server_port) catch unreachable;
    try server.listen(address);

    // Run the server
    runServer(&server) catch |err| {
        // Handle server errors
        log.err("server error: {}\n", .{err});
        if (@errorReturnTrace()) |trace| {
            debug.dumpStackTrace(trace.*);
        }
        std.os.exit(1);
    };
}