const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const terminal = @import("main.zig");
const DCS = terminal.DCS;

const log = std.log.scoped(.terminal_dcs);

/// DCS command handler. This should be hooked into a terminal.Stream handler.
/// The hook/put/unhook functions are meant to be called from the
/// terminal.stream dcsHook, dcsPut, and dcsUnhook functions, respectively.
pub const Handler = struct {
    state: State = .{ .inactive = {} },

    /// Maximum bytes any DCS command can take. This is to prevent
    /// malicious input from causing us to allocate too much memory.
    /// This is arbitrarily set to 1MB today, increase if needed.
    max_bytes: usize = 1024 * 1024,

    pub fn deinit(self: *Handler) void {
        self.discard();
    }

    pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) void {
        assert(self.state == .inactive);
        self.state = if (tryHook(alloc, dcs)) |state_| state: {
            if (state_) |state| break :state state else {
                log.info("unknown DCS hook: {}", .{dcs});
                break :state .{ .ignore = {} };
            }
        } else |err| state: {
            log.info(
                "error initializing DCS hook, will ignore hook err={}",
                .{err},
            );
            break :state .{ .ignore = {} };
        };
    }

    fn tryHook(alloc: Allocator, dcs: DCS) !?State {
        return switch (dcs.intermediates.len) {
            1 => switch (dcs.intermediates[0]) {
                '+' => switch (dcs.final) {
                    // XTGETTCAP
                    // https://github.com/mitchellh/ghostty/issues/517
                    'q' => .{
                        .xtgettcap = try std.ArrayList(u8).initCapacity(
                            alloc,
                            128, // Arbitrary choice
                        ),
                    },

                    else => null,
                },

                else => null,
            },

            else => null,
        };
    }

    pub fn put(self: *Handler, byte: u8) void {
        self.tryPut(byte) catch |err| {
            // On error we just discard our state and ignore the rest
            log.info("error putting byte into DCS handler err={}", .{err});
            self.discard();
            self.state = .{ .ignore = {} };
        };
    }

    fn tryPut(self: *Handler, byte: u8) !void {
        switch (self.state) {
            .inactive,
            .ignore,
            => {},

            .xtgettcap => |*list| {
                if (list.items.len >= self.max_bytes) {
                    return error.OutOfMemory;
                }

                try list.append(byte);
            },
        }
    }

    pub fn unhook(self: *Handler) ?Command {
        defer self.state = .{ .inactive = {} };
        return switch (self.state) {
            .inactive,
            .ignore,
            => null,

            .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } },
        };
    }

    fn discard(self: *Handler) void {
        switch (self.state) {
            .inactive,
            .ignore,
            => {},

            .xtgettcap => |*list| list.deinit(),
        }

        self.state = .{ .inactive = {} };
    }
};

pub const Command = union(enum) {
    /// XTGETTCAP
    xtgettcap: XTGETTCAP,

    pub fn deinit(self: Command) void {
        switch (self) {
            .xtgettcap => |*v| {
                v.data.deinit();
            },
        }
    }

    pub const XTGETTCAP = struct {
        data: std.ArrayList(u8),
        i: usize = 0,

        /// Returns the next terminfo key being requested and null
        /// when there are no more keys. The returned value is NOT hex-decoded
        /// because we expect to use a comptime lookup table.
        pub fn next(self: *XTGETTCAP) ?[]const u8 {
            if (self.i >= self.data.items.len) return null;

            var rem = self.data.items[self.i..];
            const idx = std.mem.indexOf(u8, rem, ";") orelse rem.len;

            // Note that if we're at the end, idx + 1 is len + 1 so we're over
            // the end but that's okay because our check above is >= so we'll
            // never read.
            self.i += idx + 1;

            return rem[0..idx];
        }
    };
};

const State = union(enum) {
    /// We're not in a DCS state at the moment.
    inactive: void,

    /// We're hooked, but its an unknown DCS command or one that went
    /// invalid due to some bad input, so we're ignoring the rest.
    ignore: void,

    // XTGETTCAP
    xtgettcap: std.ArrayList(u8),
};

test "unknown DCS command" {
    const testing = std.testing;
    const alloc = testing.allocator;

    var h: Handler = .{};
    defer h.deinit();
    h.hook(alloc, .{ .final = 'A' });
    try testing.expect(h.state == .ignore);
    try testing.expect(h.unhook() == null);
    try testing.expect(h.state == .inactive);
}

test "XTGETTCAP command" {
    const testing = std.testing;
    const alloc = testing.allocator;

    var h: Handler = .{};
    defer h.deinit();
    h.hook(alloc, .{ .intermediates = "+", .final = 'q' });
    for ("536D756C78") |byte| h.put(byte);
    var cmd = h.unhook().?;
    defer cmd.deinit();
    try testing.expect(cmd == .xtgettcap);
    try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
    try testing.expect(cmd.xtgettcap.next() == null);
}

test "XTGETTCAP command multiple keys" {
    const testing = std.testing;
    const alloc = testing.allocator;

    var h: Handler = .{};
    defer h.deinit();
    h.hook(alloc, .{ .intermediates = "+", .final = 'q' });
    for ("536D756C78;536D756C78") |byte| h.put(byte);
    var cmd = h.unhook().?;
    defer cmd.deinit();
    try testing.expect(cmd == .xtgettcap);
    try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
    try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
    try testing.expect(cmd.xtgettcap.next() == null);
}

test "XTGETTCAP command invalid data" {
    const testing = std.testing;
    const alloc = testing.allocator;

    var h: Handler = .{};
    defer h.deinit();
    h.hook(alloc, .{ .intermediates = "+", .final = 'q' });
    for ("who;536D756C78") |byte| h.put(byte);
    var cmd = h.unhook().?;
    defer cmd.deinit();
    try testing.expect(cmd == .xtgettcap);
    try testing.expectEqualStrings("who", cmd.xtgettcap.next().?);
    try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
    try testing.expect(cmd.xtgettcap.next() == null);
}
