//! PS/2 mouse packet assembly — the byte stream a streaming mouse sends, turned
//! into decoded movement/button reports.
//!
//! A standard PS/2 mouse in stream mode sends three-byte packets:
//!
//!   byte 0: | Y ovf | X ovf | Y sign | X sign | 1 | middle | right | left |
//!   byte 1: X movement (low eight bits; the sign bit lives in byte 0)
//!   byte 2: Y movement (likewise)
//!
//! Movement is nine-bit two's complement, PS/2 convention: positive X right,
//! positive Y **up**. The decoded packet converts Y to the screen convention
//! (positive down), matching what every consumer of relative motion expects.
//! Bit 3 of byte 0 is always set — the resynchronization anchor: a byte at
//! packet start with bit 3 clear cannot be a packet header and is dropped.
//!
//! Everything here is pure (no imports beyond `std`, no IO), so it is
//! host-testable: the tests at the bottom run under `zig build test`.

const std = @import("std");

/// One decoded movement/button report, in screen convention (positive dy down).
pub const Packet = struct {
    left: bool,
    right: bool,
    middle: bool,
    dx: i16,
    dy: i16,
};

const header_always_set: u8 = 1 << 3;
const header_left: u8 = 1 << 0;
const header_right: u8 = 1 << 1;
const header_middle: u8 = 1 << 2;
const header_x_sign: u8 = 1 << 4;
const header_y_sign: u8 = 1 << 5;
const header_x_overflow: u8 = 1 << 6;
const header_y_overflow: u8 = 1 << 7;

/// Device protocol bytes that can reach the packet stream around bring-up (the
/// acknowledge to enable-reporting, a reset's self-test result). Both have bit 3
/// set, so the header check alone cannot reject them; they are recognized only
/// at packet start, where a real header cannot be one of them in practice.
const response_acknowledge: u8 = 0xFA;
const response_self_test_passed: u8 = 0xAA;

/// Accumulates the byte stream into `Packet`s. Feed it every byte the mouse
/// sends; the third byte of each well-formed packet returns one.
pub const Assembler = struct {
    bytes: [3]u8 = undefined,
    count: u8 = 0,

    pub fn feed(self: *Assembler, byte: u8) ?Packet {
        if (self.count == 0) {
            // Resynchronize: a packet must start with a plausible header.
            if (byte & header_always_set == 0) return null;
            if (byte == response_acknowledge or byte == response_self_test_passed) return null;
        }
        self.bytes[self.count] = byte;
        self.count += 1;
        if (self.count < 3) return null;
        self.count = 0;

        const header = self.bytes[0];
        // An overflowed count is garbage by definition; discard the packet.
        if (header & (header_x_overflow | header_y_overflow) != 0) return null;

        return .{
            .left = header & header_left != 0,
            .right = header & header_right != 0,
            .middle = header & header_middle != 0,
            .dx = movement(self.bytes[1], header & header_x_sign != 0),
            // PS/2 positive Y is up; screen positive Y is down.
            .dy = -movement(self.bytes[2], header & header_y_sign != 0),
        };
    }

    /// Nine-bit two's complement: the eight movement bits plus the header's sign.
    fn movement(low: u8, negative: bool) i16 {
        const value: i16 = low;
        return if (negative) value - 256 else value;
    }
};

// --- tests (host-run via `zig build test`) ------------------------------------

const testing = std.testing;

fn feedAll(assembler: *Assembler, bytes: []const u8) ?Packet {
    var result: ?Packet = null;
    for (bytes) |byte| {
        if (assembler.feed(byte)) |packet| result = packet;
    }
    return result;
}

test "plain motion decodes with screen-convention y" {
    var assembler = Assembler{};
    const packet = feedAll(&assembler, &.{ 0x08, 5, 3 }).?;
    try testing.expectEqual(@as(i16, 5), packet.dx);
    try testing.expectEqual(@as(i16, -3), packet.dy); // PS/2 up 3 -> screen -3
    try testing.expect(!packet.left and !packet.right and !packet.middle);
}

test "negative movement sign-extends through the header bits" {
    var assembler = Assembler{};
    // X sign and Y sign set: dx = 0xFB - 256 = -5, dy raw = 0xFE - 256 = -2 -> screen +2.
    const packet = feedAll(&assembler, &.{ 0x08 | 0x10 | 0x20, 0xFB, 0xFE }).?;
    try testing.expectEqual(@as(i16, -5), packet.dx);
    try testing.expectEqual(@as(i16, 2), packet.dy);
}

test "buttons decode from the header" {
    var assembler = Assembler{};
    const packet = feedAll(&assembler, &.{ 0x08 | 0x01 | 0x02, 0, 0 }).?;
    try testing.expect(packet.left);
    try testing.expect(packet.right);
    try testing.expect(!packet.middle);
}

test "a byte with bit 3 clear at packet start is dropped" {
    var assembler = Assembler{};
    // The stray 0x02 cannot be a header; the following packet still decodes.
    try testing.expectEqual(@as(?Packet, null), assembler.feed(0x02));
    const packet = feedAll(&assembler, &.{ 0x09, 1, 0 }).?;
    try testing.expect(packet.left);
    try testing.expectEqual(@as(i16, 1), packet.dx);
}

test "protocol bytes at packet start are dropped" {
    var assembler = Assembler{};
    try testing.expectEqual(@as(?Packet, null), assembler.feed(0xFA)); // enable-reporting ACK
    try testing.expectEqual(@as(?Packet, null), assembler.feed(0xAA)); // self-test passed
    const packet = feedAll(&assembler, &.{ 0x08, 7, 0 }).?;
    try testing.expectEqual(@as(i16, 7), packet.dx);
}

test "an overflowed packet is discarded whole" {
    var assembler = Assembler{};
    try testing.expectEqual(@as(?Packet, null), feedAll(&assembler, &.{ 0x08 | 0x40, 0xFF, 0xFF }));
    // The assembler is back at packet start.
    const packet = feedAll(&assembler, &.{ 0x08, 1, 1 }).?;
    try testing.expectEqual(@as(i16, 1), packet.dx);
}
