Zig 0.17.0-dev (Split by item)

This is an example of documentation generated by ZigDoc, an alternative to Zig's built-in Auto Doc feature. See also examples in other modes/formats. The project being documented here (as the example) is the Zig library itself.

ml_dsa.zig

Module-Lattice-Based Digital Signature Algorithm (ML-DSA) as specified in NIST FIPS 204.

ML-DSA is a post-quantum secure digital signature scheme based on the hardness of the Module Learning With Errors (MLWE) and Module Short Integer Solution (MSIS) problems over module lattices.

We provide three parameter sets:

File

lib/std/crypto/ml_dsa.zig

Code

//! Module-Lattice-Based Digital Signature Algorithm (ML-DSA) as specified in NIST FIPS 204.
//!
//! ML-DSA is a post-quantum secure digital signature scheme based on the hardness
//! of the Module Learning With Errors (MLWE) and Module Short Integer Solution (MSIS)
//! problems over module lattices.
//!
//! We provide three parameter sets:
//!
//! - ML-DSA-44: NIST security category 2 (128-bit security)
//! - ML-DSA-65: NIST security category 3 (192-bit security)
//! - ML-DSA-87: NIST security category 5 (256-bit security)

const std = @import("std");
const builtin = @import("builtin");
const testing = std.testing;
const assert = std.debug.assert;
const crypto = std.crypto;
const errors = std.crypto.errors;
const math = std.math;
const mem = std.mem;
const sha3 = crypto.hash.sha3;

const ContextTooLongError = errors.ContextTooLongError;
const EncodingError = errors.EncodingError;
const SignatureVerificationError = errors.SignatureVerificationError;

/// ML-DSA-44 (Module-Lattice-Based Digital Signature Algorithm, 44 parameter set)
/// as specified in NIST FIPS 204.
///
/// This is a post-quantum signature scheme providing NIST security category 2,
/// which is roughly equivalent to the security of SHA-256 or AES-128.
///
/// Key sizes:
///
/// - Public key: 1312 bytes
/// - Secret key: 2560 bytes
/// - Signature: 2420 bytes
///
/// Example usage:
///
/// ```zig
/// const kp = MLDSA44.KeyPair.generate();
/// const msg = "Hello, post-quantum world!";
/// const sig = try kp.sign(msg, null);
/// try sig.verify(msg, kp.public_key);
/// ```
pub const MLDSA44 = MLDSAImpl(.{
    .name = "ML-DSA-44",
    .k = 4,
    .l = 4,
    .eta = 2,
    .omega = 80,
    .tau = 39,
    .gamma1_bits = 17,
    .gamma2 = 95232, // (Q-1)/88
    .tr_size = 64,
    .ctilde_size = 32,
});

/// ML-DSA-65 (Module-Lattice-Based Digital Signature Algorithm, 65 parameter set)
/// as specified in NIST FIPS 204.
///
/// This is a post-quantum signature scheme providing NIST security category 3,
/// which is roughly equivalent to the security of SHA-384 or AES-192.
///
/// Key sizes:
///
/// - Public key: 1952 bytes
/// - Secret key: 4032 bytes
/// - Signature: 3309 bytes
///
/// This parameter set offers higher security than ML-DSA-44 at the cost of
/// larger keys and signatures.
pub const MLDSA65 = MLDSAImpl(.{
    .name = "ML-DSA-65",
    .k = 6,
    .l = 5,
    .eta = 4,
    .omega = 55,
    .tau = 49,
    .gamma1_bits = 19,
    .gamma2 = 261888, // (Q-1)/32
    .tr_size = 64,
    .ctilde_size = 48,
});

/// ML-DSA-87 (Module-Lattice-Based Digital Signature Algorithm, 87 parameter set)
/// as specified in NIST FIPS 204.
///
/// This is a post-quantum signature scheme providing NIST security category 5,
/// which is roughly equivalent to the security of SHA-512 or AES-256.
///
/// Key sizes:
///
/// - Public key: 2592 bytes
/// - Secret key: 4896 bytes
/// - Signature: 4627 bytes
///
/// This parameter set offers the highest security level among the three ML-DSA
/// variants, suitable for applications requiring maximum security assurance.
pub const MLDSA87 = MLDSAImpl(.{
    .name = "ML-DSA-87",
    .k = 8,
    .l = 7,
    .eta = 2,
    .omega = 75,
    .tau = 60,
    .gamma1_bits = 19,
    .gamma2 = 261888, // (Q-1)/32
    .tr_size = 64,
    .ctilde_size = 64,
});

const N: usize = 256; // Degree of polynomials
const Q: u32 = 8380417; // Modulus: 2^23 - 2^13 + 1
const Q_BITS: u32 = 23;
const D: u32 = 13; // Dropped bits in power2Round

// Montgomery constant R = 2^32 mod q
const R: u64 = 1 << 32;

// Q^(-1) mod 2^32 = -(q^-1) mod 2^32
const Q_INV: u32 = 4236238847;

// (256)^(-1) * R^2 mod q, used in inverse NTT
const R_OVER_256: u32 = 41978;

// Primitive 512th root of unity
const ZETA: u32 = 1753;

const Params = struct {
    name: []const u8,

    // Matrix dimensions
    k: u8, // Height of matrix A
    l: u8, // Width of matrix A

    // Sampling parameter
    eta: u8, // Bound for secret coefficients

    // Hint parameters
    omega: u16, // Maximum number of hint bits

    // Challenge parameter
    tau: u16, // Weight of challenge polynomial

    // Rounding parameters
    gamma1_bits: u8, // Bits for gamma1
    gamma2: u32, // Parameter for decompose

    // Sizes
    tr_size: usize, // Size of tr hash
    ctilde_size: usize, // Size of challenge hash
};

const Poly = struct {
    cs: [N]u32,

    const zero: Poly = .{ .cs = @splat(0) };

    // Add two polynomials (no normalization)
    fn add(a: Poly, b: Poly) Poly {
        var ret: Poly = undefined;
        for (0..N) |i| {
            ret.cs[i] = a.cs[i] + b.cs[i];
        }
        return ret;
    }

    // Subtract two polynomials (assumes b coefficients < 2q)
    fn sub(a: Poly, b: Poly) Poly {
        var ret: Poly = undefined;
        for (0..N) |i| {
            ret.cs[i] = a.cs[i] +% (@as(u32, 2 * Q) -% b.cs[i]);
        }
        return ret;
    }

    // Reduce each coefficient to < 2q
    fn reduceLe2Q(p: Poly) Poly {
        var ret = p;
        for (0..N) |i| {
            ret.cs[i] = le2Q(ret.cs[i]);
        }
        return ret;
    }

    // Normalize coefficients to [0, q)
    fn normalize(p: Poly) Poly {
        var ret = p;
        for (0..N) |i| {
            ret.cs[i] = modQ(ret.cs[i]);
        }
        return ret;
    }

    // Normalize assuming coefficients already < 2q
    fn normalizeAssumingLe2Q(p: Poly) Poly {
        var ret = p;
        for (0..N) |i| {
            ret.cs[i] = le2qModQ(ret.cs[i]);
        }
        return ret;
    }

    // Pointwise multiplication in NTT domain (Montgomery form)
    fn mulHat(a: Poly, b: Poly) Poly {
        var ret: Poly = undefined;
        for (0..N) |i| {
            ret.cs[i] = montReduceLe2Q(@as(u64, a.cs[i]) * @as(u64, b.cs[i]));
        }
        return ret;
    }

    // Forward NTT
    fn ntt(p: Poly) Poly {
        var ret = p;
        ret.nttInPlace();
        return ret;
    }

    // In-place forward NTT
    fn nttInPlace(p: *Poly) void {
        var k: usize = 0;
        var l: usize = N / 2;

        while (l > 0) : (l >>= 1) {
            var offset: usize = 0;
            while (offset < N - l) : (offset += 2 * l) {
                k += 1;
                const zeta: u64 = zetas[k];

                for (offset..offset + l) |j| {
                    const t = montReduceLe2Q(zeta * @as(u64, p.cs[j + l]));
                    p.cs[j + l] = p.cs[j] +% (2 * Q -% t);
                    p.cs[j] +%= t;
                }
            }
        }
    }

    // Inverse NTT
    fn invNTT(p: Poly) Poly {
        var ret = p;
        ret.invNTTInPlace();
        return ret;
    }

    // In-place inverse NTT
    fn invNTTInPlace(p: *Poly) void {
        var k: usize = 0;
        var l: usize = 1;

        while (l < N) : (l <<= 1) {
            var offset: usize = 0;
            while (offset < N - l) : (offset += 2 * l) {
                const zeta: u64 = inv_zetas[k];
                k += 1;

                for (offset..offset + l) |j| {
                    const t = p.cs[j];
                    p.cs[j] = t +% p.cs[j + l];
                    p.cs[j + l] = montReduceLe2Q(zeta * @as(u64, t +% 256 * Q -% p.cs[j + l]));
                }
            }
        }

        for (0..N) |j| {
            p.cs[j] = montReduceLe2Q(@as(u64, R_OVER_256) * @as(u64, p.cs[j]));
        }
    }

    /// Apply Power2Round to all coefficients
    /// Returns both t0 and t1 polynomials
    fn power2RoundPoly(p: Poly) struct { t0: Poly, t1: Poly } {
        var t0 = Poly.zero;
        var t1 = Poly.zero;
        for (0..N) |i| {
            const result = power2Round(p.cs[i]);
            t0.cs[i] = result.a0_plus_q;
            t1.cs[i] = result.a1;
        }
        return .{ .t0 = t0, .t1 = t1 };
    }

    // Check if infinity norm exceeds bound
    fn exceeds(p: Poly, bound: u32) bool {
        var result: u32 = 0;
        for (0..N) |i| {
            const x = @as(i32, @intCast((Q - 1) / 2)) - @as(i32, @intCast(p.cs[i]));
            const abs_x = x ^ (x >> 31);
            const norm = @as(i32, @intCast((Q - 1) / 2)) - abs_x;
            const exceeds_bit = @intFromBool(@as(u32, @intCast(norm)) >= bound);
            result |= exceeds_bit;
        }
        return result != 0;
    }
};

fn PolyVec(comptime len: u8) type {
    return struct {
        ps: [len]Poly,

        const Self = @This();
        const zero: Self = .{ .ps = @splat(.zero) };

        /// Apply a unary operation to each polynomial in the vector
        fn map(v: Self, comptime op: fn (Poly) Poly) Self {
            var ret: Self = undefined;
            inline for (0..len) |i| {
                ret.ps[i] = op(v.ps[i]);
            }
            return ret;
        }

        /// Apply a binary operation pairwise to two vectors
        fn mapBinary(a: Self, b: Self, comptime op: fn (Poly, Poly) Poly) Self {
            var ret: Self = undefined;
            inline for (0..len) |i| {
                ret.ps[i] = op(a.ps[i], b.ps[i]);
            }
            return ret;
        }

        /// Apply a binary operation between a vector and a scalar polynomial
        fn mapBinaryPoly(v: Self, scalar: Poly, comptime op: fn (Poly, Poly) Poly) Self {
            var ret: Self = undefined;
            inline for (0..len) |i| {
                ret.ps[i] = op(v.ps[i], scalar);
            }
            return ret;
        }

        fn add(a: Self, b: Self) Self {
            return mapBinary(a, b, Poly.add);
        }

        fn sub(a: Self, b: Self) Self {
            return mapBinary(a, b, Poly.sub);
        }

        fn ntt(v: Self) Self {
            return map(v, Poly.ntt);
        }

        fn invNTT(v: Self) Self {
            return map(v, Poly.invNTT);
        }

        fn normalize(v: Self) Self {
            return map(v, Poly.normalize);
        }

        fn reduceLe2Q(v: Self) Self {
            return map(v, Poly.reduceLe2Q);
        }

        fn normalizeAssumingLe2Q(v: Self) Self {
            return map(v, Poly.normalizeAssumingLe2Q);
        }

        // Check if any polynomial in the vector exceeds the bound
        fn exceeds(v: Self, bound: u32) bool {
            var result = false;
            for (0..len) |i| {
                result = result or v.ps[i].exceeds(bound);
            }
            return result;
        }

        /// Apply Power2Round to each polynomial in the vector
        /// Returns both t0 and t1 vectors
        fn power2Round(v: Self, t0_out: *Self) Self {
            var t1: Self = undefined;
            for (0..len) |i| {
                const result = v.ps[i].power2RoundPoly();
                t0_out.ps[i] = result.t0;
                t1.ps[i] = result.t1;
            }
            return t1;
        }

        /// Generic packing function for vectors
        fn packWith(
            v: Self,
            buf: []u8,
            comptime poly_size: usize,
            comptime pack_fn: fn (Poly, []u8) void,
        ) void {
            inline for (0..len) |i| {
                const offset = i * poly_size;
                pack_fn(v.ps[i], buf[offset..][0..poly_size]);
            }
        }

        /// Generic unpacking function for vectors
        fn unpackWith(
            comptime poly_size: usize,
            comptime unpack_fn: fn ([]const u8) Poly,
            buf: []const u8,
        ) Self {
            var result: Self = undefined;
            inline for (0..len) |i| {
                const offset = i * poly_size;
                result.ps[i] = unpack_fn(buf[offset..][0..poly_size]);
            }
            return result;
        }

        /// Pack T1 vector to bytes
        fn packT1(v: Self, buf: []u8) void {
            const poly_size = (N * (Q_BITS - D)) / 8;
            packWith(v, buf, poly_size, polyPackT1);
        }

        /// Unpack T1 vector from bytes
        fn unpackT1(bytes: []const u8) Self {
            const poly_size = (N * (Q_BITS - D)) / 8;
            return unpackWith(poly_size, polyUnpackT1, bytes);
        }

        /// Pack T0 vector to bytes
        fn packT0(v: Self, buf: []u8) void {
            const poly_size = (N * D) / 8;
            packWith(v, buf, poly_size, polyPackT0);
        }

        /// Unpack T0 vector from bytes
        fn unpackT0(buf: []const u8) Self {
            const poly_size = (N * D) / 8;
            return unpackWith(poly_size, polyUnpackT0, buf);
        }

        /// Pack vector with coefficients in [-eta, eta]
        fn packLeqEta(v: Self, comptime eta: u8, buf: []u8) void {
            const poly_size = if (eta == 2) 96 else 128;
            const pack_fn = struct {
                fn pack(p: Poly, b: []u8) void {
                    polyPackLeqEta(p, eta, b);
                }
            }.pack;
            packWith(v, buf, poly_size, pack_fn);
        }

        /// Unpack vector with coefficients in [-eta, eta]
        fn unpackLeqEta(comptime eta: u8, buf: []const u8) Self {
            const poly_size = if (eta == 2) 96 else 128;
            const unpack_fn = struct {
                fn unpack(b: []const u8) Poly {
                    return polyUnpackLeqEta(eta, b);
                }
            }.unpack;
            return unpackWith(poly_size, unpack_fn, buf);
        }

        /// Pack vector of polynomials with coefficients < gamma1
        fn packLeGamma1(v: Self, comptime gamma1_bits: u8, buf: []u8) void {
            const poly_size = ((gamma1_bits + 1) * N) / 8;
            const pack_fn = struct {
                fn pack(p: Poly, b: []u8) void {
                    polyPackLeGamma1(p, gamma1_bits, b);
                }
            }.pack;
            packWith(v, buf, poly_size, pack_fn);
        }

        /// Unpack vector of polynomials with coefficients < gamma1
        fn unpackLeGamma1(comptime gamma1_bits: u8, buf: []const u8) Self {
            const poly_size = ((gamma1_bits + 1) * N) / 8;
            const unpack_fn = struct {
                fn unpack(b: []const u8) Poly {
                    return polyUnpackLeGamma1(gamma1_bits, b);
                }
            }.unpack;
            return unpackWith(poly_size, unpack_fn, buf);
        }

        /// Pack high bits w1 for signature verification
        fn packW1(v: Self, comptime gamma1_bits: u8, buf: []u8) void {
            const poly_size = (N * (Q_BITS - gamma1_bits)) / 8;
            const pack_fn = struct {
                fn pack(p: Poly, b: []u8) void {
                    polyPackW1(p, gamma1_bits, b);
                }
            }.pack;
            packWith(v, buf, poly_size, pack_fn);
        }

        /// Decompose each polynomial in the vector into high and low bits
        fn decomposeVec(v: Self, comptime gamma2: u32, w0_out: *Self) Self {
            var w1: Self = undefined;
            for (0..len) |i| {
                for (0..N) |j| {
                    const r = decompose(v.ps[i].cs[j], gamma2);
                    w0_out.ps[i].cs[j] = r.a0_plus_q;
                    w1.ps[i].cs[j] = r.a1;
                }
            }
            return w1;
        }

        /// Create hints for vector, returns hint population count
        fn makeHintVec(w0mcs2pct0: Self, w1: Self, comptime gamma2: u32) struct { hint: Self, pop: u32 } {
            var hint: Self = undefined;
            var pop: u32 = 0;
            for (0..len) |i| {
                const result = polyMakeHint(w0mcs2pct0.ps[i], w1.ps[i], gamma2);
                hint.ps[i] = result.hint;
                pop += result.count;
            }
            return .{ .hint = hint, .pop = pop };
        }

        /// Apply hints to recover high bits
        fn useHint(v: Self, hint: Self, comptime gamma2: u32) Self {
            var result: Self = undefined;
            for (0..len) |i| {
                result.ps[i] = polyUseHint(v.ps[i], hint.ps[i], gamma2);
            }
            return result;
        }

        /// Multiply vector by 2^D (left shift)
        fn mulBy2toD(v: Self) Self {
            var result: Self = undefined;
            for (0..len) |i| {
                for (0..N) |j| {
                    result.ps[i].cs[j] = v.ps[i].cs[j] << D;
                }
            }
            return result;
        }

        /// Sample vector with coefficients uniformly in (-gamma1, gamma1]
        /// Wraps expandMask (FIPS 204: ExpandMask)
        fn deriveUniformLeGamma1(comptime gamma1_bits: u8, seed: *const [64]u8, nonce: u16) Self {
            var result: Self = undefined;
            for (0..len) |i| {
                result.ps[i] = expandMask(gamma1_bits, seed, nonce + @as(u16, @intCast(i)));
            }
            return result;
        }

        /// Pack hints into bytes
        /// Format: for each polynomial, find positions where hint[i]=1, encode those positions
        fn packHint(v: Self, comptime omega: u16, buf: []u8) bool {
            var idx: usize = 0;
            var count: u32 = 0;

            for (0..len) |i| {
                for (0..N) |j| {
                    if (v.ps[i].cs[j] != 0) {
                        count += 1;
                    }
                }
            }

            if (count > omega) {
                return false;
            }

            // Hint encoding format per FIPS 204:
            // First omega bytes: positions of set bits across all polynomials
            // Last len bytes: boundary indices showing where each polynomial's hints end
            for (0..len) |i| {
                for (0..N) |j| {
                    if (v.ps[i].cs[j] != 0) {
                        buf[idx] = @intCast(j);
                        idx += 1;
                    }
                }
                buf[omega + i] = @intCast(idx);
            }

            while (idx < omega) : (idx += 1) {
                buf[idx] = 0;
            }

            return true;
        }

        /// Unpack hints from bytes
        fn unpackHint(comptime omega: u16, buf: []const u8) ?Self {
            var result: Self = .{ .ps = @splat(.zero) };
            var prev_sop: u8 = 0; // previous switch-over-point

            for (0..len) |i| {
                const sop = buf[omega + i]; // switch-over-point
                if (sop < prev_sop or sop > omega) {
                    return null; // ensures switch-over-points are increasing
                }

                var j = prev_sop;
                while (j < sop) : (j += 1) {
                    // Validation: indices must be strictly increasing within each polynomial
                    if (j > prev_sop and buf[j] <= buf[j - 1]) {
                        return null;
                    }
                    const pos = buf[j];
                    if (pos >= N) {
                        return null;
                    }
                    result.ps[i].cs[pos] = 1;
                }
                prev_sop = sop;
            }

            var j = prev_sop;
            while (j < omega) : (j += 1) {
                if (buf[j] != 0) {
                    return null;
                }
            }

            return result;
        }
    };
}

// Matrix of k x l polynomials

fn Mat(comptime k: u8, comptime l: u8) type {
    return struct {
        rows: [k]PolyVec(l),

        const Self = @This();
        const VecL = PolyVec(l);
        const VecK = PolyVec(k);

        /// Expand matrix A from seed rho using SHAKE-128
        /// This is the ExpandA function from FIPS 204
        fn derive(rho: *const [32]u8) Self {
            var m: Self = undefined;
            for (0..k) |i| {
                if (i + 1 < k) {
                    @prefetch(&m.rows[i + 1], .{ .rw = .write, .locality = 2 });
                }
                for (0..l) |j| {
                    // Nonce is i*256 + j
                    const nonce: u16 = (@as(u16, @intCast(i)) << 8) | @as(u16, @intCast(j));
                    m.rows[i].ps[j] = polyDeriveUniform(rho, nonce);
                }
            }
            return m;
        }

        /// Multiply matrix by vector in NTT domain and return result in regular domain.
        /// Takes a vector in NTT form and returns the product in regular form.
        fn mulVec(self: Self, v_hat: VecL) VecK {
            var result = VecK.zero;
            for (0..k) |i| {
                result.ps[i] = dotHat(l, self.rows[i], v_hat);
                result.ps[i] = result.ps[i].reduceLe2Q();
                result.ps[i] = result.ps[i].invNTT();
            }
            return result;
        }

        /// Multiply matrix by vector in NTT domain and return result in NTT domain.
        /// Takes a vector in NTT form and returns the product in NTT form.
        fn mulVecHat(self: Self, v_hat: VecL) VecK {
            var result: VecK = undefined;
            for (0..k) |i| {
                result.ps[i] = dotHat(l, self.rows[i], v_hat);
            }
            return result;
        }
    };
}

// Dot product in NTT domain
fn dotHat(comptime len: u8, a: PolyVec(len), b: PolyVec(len)) Poly {
    var ret = Poly.zero;
    for (0..len) |i| {
        const prod = a.ps[i].mulHat(b.ps[i]);
        ret = ret.add(prod);
    }
    return ret;
}

// Modular arithmetic operations

// Reduce x to [0, 2q) using the fact that 2^23 = 2^13 - 1 (mod q)
fn le2Q(x: u32) u32 {
    // Write x = x1 * 2^23 + x2 with x2 < 2^23 and x1 < 2^9
    // Then x = x2 + x1 * 2^13 - x1 (mod q)
    // and x2 + x1 * 2^13 - x1 <= 2^23 + 2^13 < 2q
    const x1 = x >> 23;
    const x2 = x & 0x7FFFFF; // 2^23 - 1
    return x2 +% (x1 << 13) -% x1;
}

// Reduce x to [0, q)
fn modQ(x: u32) u32 {
    return le2qModQ(le2Q(x));
}

// Given x < 2q, reduce to [0, q)
fn le2qModQ(x: u32) u32 {
    const r = x -% Q;
    const mask = signMask(u32, r);
    return r +% (mask & Q);
}

// Montgomery reduction: for x < q*2^32, return y < 2q where y ≡ x*R^(-1) (mod q)
// where R = 2^32. This is used for efficient modular multiplication in NTT operations.
fn montReduceLe2Q(x: u64) u32 {
    const m = (x *% Q_INV) & 0xffffffff;
    return @truncate((x +% m * @as(u64, Q)) >> 32);
}

// Precomputed zetas for NTT (Montgomery form)
// zetas[i] = zeta^brv(i) * R mod q
const zetas = computeZetas();

fn computeZetas() [N]u32 {
    @setEvalBranchQuota(100000);
    var ret: [N]u32 = undefined;

    for (0..N) |i| {
        const brv_i = @bitReverse(@as(u8, @intCast(i)));
        const power = modularPow(u32, ZETA, brv_i, Q);
        ret[i] = toMont(power);
    }

    return ret;
}

// Precomputed inverse zetas for inverse NTT
const inv_zetas = computeInvZetas();

fn computeInvZetas() [N]u32 {
    @setEvalBranchQuota(100000);
    var ret: [N]u32 = undefined;

    const inv_zeta = modularInverse(u32, ZETA, Q);

    for (0..N) |i| {
        const idx = 255 - i;
        const brv_idx = @bitReverse(@as(u8, @intCast(idx)));

        // Exponent is -(brv_idx - 256) = 256 - brv_idx
        const exp: u32 = @as(u32, 256) - brv_idx;

        // Compute inv_zeta^exp
        const power = modularPow(u32, inv_zeta, exp, Q);

        // Convert to Montgomery form
        ret[i] = toMont(power);
    }

    return ret;
}

// Convert to Montgomery form: x -> x * R mod q
fn toMont(x: u32) u32 {
    // R = 2^32, R mod q can be computed as:
    // 2^32 mod q = 2^32 mod (2^23 - 2^13 + 1)
    // Using the identity 2^23 = 2^13 - 1 (mod q), we can reduce 2^32
    // But it's easier to just do: return montReduce(x * R^2 mod q)
    // where R^2 mod q is precomputed

    // Computing R^2 mod q:
    // R = 2^32, so R^2 = 2^64
    // We can compute this by noting that R mod q first:
    // 2^32 = 2^32 mod q
    // But let's use a simpler approach: multiply x by R in the Montgomery domain
    // Actually, the simplest is: x * R mod q = montReduceLe2Q(x * R^2 mod q)

    // Precompute R^2 mod q at comptime
    const r_mod_q = comptime blk: {
        // 2^32 mod q - compute by successive squaring
        var r: u64 = 1;
        for (0..32) |_| {
            r = (r * 2) % Q;
        }
        break :blk @as(u32, @intCast(r));
    };

    const r2_mod_q = comptime blk: {
        const r = @as(u64, r_mod_q);
        break :blk @as(u32, @intCast((r * r) % Q));
    };

    return montReduceLe2Q(@as(u64, x) * @as(u64, r2_mod_q));
}

/// Splits 0 ≤ a < Q into a0 and a1 with a = a1*2^D + a0
/// and -2^(D-1) < a0 ≤ 2^(D-1). Returns a0 + Q and a1.
/// FIPS 204: Power2Round (Algorithm 19)
fn power2Round(a: u32) struct { a0_plus_q: u32, a1: u32 } {
    // We effectively compute a0 = a mod± 2^D
    //                    and a1 = (a - a0) / 2^D
    var a0 = a & ((1 << D) - 1); // a mod 2^D

    // a0 is one of 0, 1, ..., 2^(D-1)-1, 2^(D-1), 2^(D-1)+1, ..., 2^D-1
    a0 -%= (1 << (D - 1)) + 1;
    // now a0 is -2^(D-1)-1, -2^(D-1), ..., -2, -1, 0, ..., 2^(D-1)-2

    // Next, add 2^D to those a0 that are negative (seen as i32)
    a0 +%= @as(u32, @bitCast(@as(i32, @bitCast(a0)) >> 31)) & (1 << D);
    // now a0 is 2^(D-1)-1, 2^(D-1), ..., 2^D-2, 2^D-1, 0, ..., 2^(D-1)-2

    a0 -%= (1 << (D - 1)) - 1;
    // now a0 is 0, 1, 2, ..., 2^(D-1)-1, 2^(D-1), -2^(D-1)+1, ..., -1

    const a0_plus_q = Q +% a0;
    const a1 = (a -% a0) >> D;

    return .{ .a0_plus_q = a0_plus_q, .a1 = a1 };
}

/// Splits 0 ≤ a < q into a0 and a1 with a = a1*alpha + a0 with -alpha/2 < a0 ≤ alpha/2,
/// except when we would have a1 = (q-1)/alpha in which case a1=0 is taken
/// and -alpha/2 ≤ a0 < 0. Returns a0 + q. Note 0 ≤ a1 < (q-1)/alpha.
/// Recall alpha = 2*gamma2.
fn decompose(a: u32, comptime gamma2: u32) struct { a0_plus_q: u32, a1: u32 } {
    const alpha = 2 * gamma2;

    // a1 = ⌈a / 128⌉
    var a1 = (a + 127) >> 7;

    if (alpha == 523776) {
        // For ML-DSA-87: gamma2 = 261888, alpha = 523776
        // 1025/2^22 is close enough to 1/4092 so that a1 becomes a/alpha rounded down
        a1 = ((a1 * 1025 + (1 << 21)) >> 22);

        // For the corner-case a1 = (q-1)/alpha = 16, we have to set a1=0
        a1 &= 15;
    } else if (alpha == 190464) {
        // For ML-DSA-65: gamma2 = 95232, alpha = 190464
        // 11275/2^24 is close enough to 1/1488 so that a1 becomes a/alpha rounded down
        a1 = ((a1 * 11275) + (1 << 23)) >> 24;

        // For the corner-case a1 = (q-1)/alpha = 44, we have to set a1=0
        a1 ^= @as(u32, @bitCast(@as(i32, @bitCast(43 -% a1)) >> 31)) & a1;
    } else {
        @compileError("unsupported gamma2/alpha value");
    }

    var a0_plus_q = a -% a1 * alpha;

    // In the corner-case, when we set a1=0, we will incorrectly
    // have a0 > (q-1)/2 and we'll need to subtract q. As we
    // return a0 + q, that comes down to adding q if a0 < (q-1)/2.
    a0_plus_q +%= @as(u32, @bitCast(@as(i32, @bitCast(a0_plus_q -% (Q - 1) / 2)) >> 31)) & Q;

    return .{ .a0_plus_q = a0_plus_q, .a1 = a1 };
}

/// Creates a hint bit to help recover high bits after a small perturbation.
/// Given:
/// - z0: the modified low bits (r0 - f mod Q) where f is small
/// - r1: the original high bits
/// Returns 1 if a hint is needed, 0 otherwise.
///
/// This implements makeHint from FIPS 204. The hint helps recover r1 from
/// r' = r - f without knowing f explicitly.
fn makeHint(z0: u32, r1: u32, comptime gamma2: u32) u32 {
    // If -alpha/2 < r0 - f <= alpha/2, then r1*alpha + r0 - f is a valid
    // decomposition of r' with the restrictions of decompose() and so r'1 = r1.
    // So the hint should be 0. This is covered by the first two inequalities.
    // There is one other case: if r0 - f = -alpha/2, then r1*alpha + r0 - f is
    // also a valid decomposition if r1 = 0. In the other cases a one is carried
    // and the hint should be 1.

    const cond1 = @intFromBool(z0 <= gamma2);
    const cond2 = @intFromBool(z0 > Q - gamma2);
    const eq_gamma2 = @intFromBool(z0 == Q - gamma2);
    const r1_is_zero = @intFromBool(r1 == 0);
    const cond3 = eq_gamma2 & r1_is_zero;

    return 1 - (cond1 | cond2 | cond3);
}

/// Uses a hint to reconstruct high bits from a perturbed value.
/// Given:
/// - rp: the perturbed value (r' = r - f)
/// - hint: the hint bit from makeHint
/// Returns the reconstructed high bits r1.
///
/// This implements useHint from FIPS 204.
fn useHint(rp: u32, hint: u32, comptime gamma2: u32) u32 {
    const decomp = decompose(rp, gamma2);
    const rp0_plus_q = decomp.a0_plus_q;
    var rp1 = decomp.a1;

    if (hint == 0) {
        return rp1;
    }

    // Depending on gamma2, handle the adjustment differently
    if (gamma2 == 261888) {
        // ML-DSA-65 and ML-DSA-87: max r1 is 15
        if (rp0_plus_q > Q) {
            rp1 = (rp1 + 1) & 15;
        } else {
            rp1 = (rp1 -% 1) & 15;
        }
    } else if (gamma2 == 95232) {
        // ML-DSA-44: max r1 is 43
        if (rp0_plus_q > Q) {
            if (rp1 == 43) {
                rp1 = 0;
            } else {
                rp1 += 1;
            }
        } else {
            if (rp1 == 0) {
                rp1 = 43;
            } else {
                rp1 -= 1;
            }
        }
    } else {
        @compileError("unsupported gamma2 value");
    }

    return rp1;
}

/// Creates a hint polynomial for the difference between perturbed and original high bits.
/// Returns the number of hint bits set to 1 (the population count).
///
/// This is used during signature generation to create hints that help verification
/// recover the high bits without access to the secret.
fn polyMakeHint(p0: Poly, p1: Poly, comptime gamma2: u32) struct { hint: Poly, count: u32 } {
    var hint = Poly.zero;
    var count: u32 = 0;

    for (0..N) |i| {
        const h = makeHint(p0.cs[i], p1.cs[i], gamma2);
        hint.cs[i] = h;
        count += h;
    }

    return .{ .hint = hint, .count = count };
}

/// Applies hints to reconstruct high bits from a perturbed polynomial.
///
/// This is used during signature verification to recover the high bits
/// using the hints provided in the signature.
fn polyUseHint(q: Poly, hint: Poly, comptime gamma2: u32) Poly {
    var result = Poly.zero;

    for (0..N) |i| {
        result.cs[i] = useHint(q.cs[i], hint.cs[i], gamma2);
    }

    return result;
}

/// Pack polynomial with coefficients in [Q-eta, Q+eta] into bytes.
/// For eta=2: packs coefficients into 3 bits each (96 bytes total)
/// For eta=4: packs coefficients into 4 bits each (128 bytes total)
/// Assumes coefficients are not normalized, but in [q-η, q+η].
fn polyPackLeqEta(p: Poly, comptime eta: u8, buf: []u8) void {
    comptime {
        if (eta != 2 and eta != 4) {
            @compileError("eta must be 2 or 4");
        }
    }

    if (eta == 2) {
        // 3 bits per coefficient: pack 8 coefficients into 3 bytes
        var j: usize = 0;
        var i: usize = 0;
        while (i < buf.len) : (i += 3) {
            const c0 = Q + eta - p.cs[j];
            const c1 = Q + eta - p.cs[j + 1];
            const c2 = Q + eta - p.cs[j + 2];
            const c3 = Q + eta - p.cs[j + 3];
            const c4 = Q + eta - p.cs[j + 4];
            const c5 = Q + eta - p.cs[j + 5];
            const c6 = Q + eta - p.cs[j + 6];
            const c7 = Q + eta - p.cs[j + 7];

            buf[i] = @truncate(c0 | (c1 << 3) | (c2 << 6));
            buf[i + 1] = @truncate((c2 >> 2) | (c3 << 1) | (c4 << 4) | (c5 << 7));
            buf[i + 2] = @truncate((c5 >> 1) | (c6 << 2) | (c7 << 5));

            j += 8;
        }
    } else { // eta == 4
        // 4 bits per coefficient: pack 2 coefficients into 1 byte
        var j: usize = 0;
        for (0..buf.len) |i| {
            const c0 = Q + eta - p.cs[j];
            const c1 = Q + eta - p.cs[j + 1];
            buf[i] = @truncate(c0 | (c1 << 4));
            j += 2;
        }
    }
}

/// Unpack polynomial with coefficients in [Q-eta, Q+eta] from bytes.
/// Output coefficients will not be normalized, but in [q-η, q+η].
fn polyUnpackLeqEta(comptime eta: u8, buf: []const u8) Poly {
    comptime {
        if (eta != 2 and eta != 4) {
            @compileError("eta must be 2 or 4");
        }
    }

    var p = Poly.zero;

    if (eta == 2) {
        // 3 bits per coefficient: unpack 8 coefficients from 3 bytes
        var j: usize = 0;
        var i: usize = 0;
        while (i < buf.len) : (i += 3) {
            p.cs[j] = Q + eta - (buf[i] & 7);
            p.cs[j + 1] = Q + eta - ((buf[i] >> 3) & 7);
            p.cs[j + 2] = Q + eta - ((buf[i] >> 6) | ((buf[i + 1] << 2) & 7));
            p.cs[j + 3] = Q + eta - ((buf[i + 1] >> 1) & 7);
            p.cs[j + 4] = Q + eta - ((buf[i + 1] >> 4) & 7);
            p.cs[j + 5] = Q + eta - ((buf[i + 1] >> 7) | ((buf[i + 2] << 1) & 7));
            p.cs[j + 6] = Q + eta - ((buf[i + 2] >> 2) & 7);
            p.cs[j + 7] = Q + eta - ((buf[i + 2] >> 5) & 7);
            j += 8;
        }
    } else { // eta == 4
        // 4 bits per coefficient: unpack 2 coefficients from 1 byte
        var j: usize = 0;
        for (0..buf.len) |i| {
            p.cs[j] = Q + eta - (buf[i] & 15);
            p.cs[j + 1] = Q + eta - (buf[i] >> 4);
            j += 2;
        }
    }

    return p;
}

/// Pack polynomial with coefficients < 1024 (T1) into bytes.
/// Packs 10 bits per coefficient: 4 coefficients into 5 bytes.
/// Assumes coefficients are normalized.
fn polyPackT1(p: Poly, buf: []u8) void {
    var j: usize = 0;
    var i: usize = 0;
    while (i < buf.len) : (i += 5) {
        buf[i] = @truncate(p.cs[j]);
        buf[i + 1] = @truncate((p.cs[j] >> 8) | (p.cs[j + 1] << 2));
        buf[i + 2] = @truncate((p.cs[j + 1] >> 6) | (p.cs[j + 2] << 4));
        buf[i + 3] = @truncate((p.cs[j + 2] >> 4) | (p.cs[j + 3] << 6));
        buf[i + 4] = @truncate(p.cs[j + 3] >> 2);
        j += 4;
    }
}

/// Unpack polynomial with coefficients < 1024 (T1) from bytes.
/// Output coefficients will be normalized.
fn polyUnpackT1(buf: []const u8) Poly {
    var p = Poly.zero;
    var j: usize = 0;
    var i: usize = 0;
    while (i < buf.len) : (i += 5) {
        p.cs[j] = (@as(u32, buf[i]) | (@as(u32, buf[i + 1]) << 8)) & 0x3ff;
        p.cs[j + 1] = ((@as(u32, buf[i + 1]) >> 2) | (@as(u32, buf[i + 2]) << 6)) & 0x3ff;
        p.cs[j + 2] = ((@as(u32, buf[i + 2]) >> 4) | (@as(u32, buf[i + 3]) << 4)) & 0x3ff;
        p.cs[j + 3] = ((@as(u32, buf[i + 3]) >> 6) | (@as(u32, buf[i + 4]) << 2)) & 0x3ff;
        j += 4;
    }
    return p;
}

/// Pack polynomial with coefficients in (-2^(D-1), 2^(D-1)] (T0) into bytes.
/// Packs 13 bits per coefficient: 8 coefficients into 13 bytes.
/// Assumes coefficients are not normalized, but in (q-2^(D-1), q+2^(D-1)].
fn polyPackT0(p: Poly, buf: []u8) void {
    const bound = 1 << (D - 1);
    var j: usize = 0;
    var i: usize = 0;
    while (i < buf.len) : (i += 13) {
        const p0 = Q + bound - p.cs[j];
        const p1 = Q + bound - p.cs[j + 1];
        const p2 = Q + bound - p.cs[j + 2];
        const p3 = Q + bound - p.cs[j + 3];
        const p4 = Q + bound - p.cs[j + 4];
        const p5 = Q + bound - p.cs[j + 5];
        const p6 = Q + bound - p.cs[j + 6];
        const p7 = Q + bound - p.cs[j + 7];

        buf[i] = @truncate(p0 >> 0);
        buf[i + 1] = @truncate((p0 >> 8) | (p1 << 5));
        buf[i + 2] = @truncate(p1 >> 3);
        buf[i + 3] = @truncate((p1 >> 11) | (p2 << 2));
        buf[i + 4] = @truncate((p2 >> 6) | (p3 << 7));
        buf[i + 5] = @truncate(p3 >> 1);
        buf[i + 6] = @truncate((p3 >> 9) | (p4 << 4));
        buf[i + 7] = @truncate(p4 >> 4);
        buf[i + 8] = @truncate((p4 >> 12) | (p5 << 1));
        buf[i + 9] = @truncate((p5 >> 7) | (p6 << 6));
        buf[i + 10] = @truncate(p6 >> 2);
        buf[i + 11] = @truncate((p6 >> 10) | (p7 << 3));
        buf[i + 12] = @truncate(p7 >> 5);

        j += 8;
    }
}

/// Unpack polynomial with coefficients in (-2^(D-1), 2^(D-1)] (T0) from bytes.
/// Output coefficients will not be normalized, but in (-2^(D-1), 2^(D-1)].
fn polyUnpackT0(buf: []const u8) Poly {
    const bound = 1 << (D - 1);
    var p = Poly.zero;
    var j: usize = 0;
    var i: usize = 0;
    while (i < buf.len) : (i += 13) {
        p.cs[j] = Q + bound - ((@as(u32, buf[i]) | (@as(u32, buf[i + 1]) << 8)) & 0x1fff);
        p.cs[j + 1] = Q + bound - (((@as(u32, buf[i + 1]) >> 5) | (@as(u32, buf[i + 2]) << 3) | (@as(u32, buf[i + 3]) << 11)) & 0x1fff);
        p.cs[j + 2] = Q + bound - (((@as(u32, buf[i + 3]) >> 2) | (@as(u32, buf[i + 4]) << 6)) & 0x1fff);
        p.cs[j + 3] = Q + bound - (((@as(u32, buf[i + 4]) >> 7) | (@as(u32, buf[i + 5]) << 1) | (@as(u32, buf[i + 6]) << 9)) & 0x1fff);
        p.cs[j + 4] = Q + bound - (((@as(u32, buf[i + 6]) >> 4) | (@as(u32, buf[i + 7]) << 4) | (@as(u32, buf[i + 8]) << 12)) & 0x1fff);
        p.cs[j + 5] = Q + bound - (((@as(u32, buf[i + 8]) >> 1) | (@as(u32, buf[i + 9]) << 7)) & 0x1fff);
        p.cs[j + 6] = Q + bound - (((@as(u32, buf[i + 9]) >> 6) | (@as(u32, buf[i + 10]) << 2) | (@as(u32, buf[i + 11]) << 10)) & 0x1fff);
        p.cs[j + 7] = Q + bound - ((@as(u32, buf[i + 11]) >> 3) | (@as(u32, buf[i + 12]) << 5));
        j += 8;
    }
    return p;
}

/// Convert coefficient from centered representation to non-negative.
/// Transforms value from [0,γ₁] ∪ (Q-γ₁, Q) to [0, 2γ₁).
fn centeredToPositive(val: u32, comptime gamma1: u32) u32 {
    var result = gamma1 -% val;
    result +%= (signMask(u32, result) & Q);
    return result;
}

/// Pack polynomial with coefficients in (-gamma1, gamma1] into bytes.
/// For gamma1_bits=17: packs 18 bits per coefficient (4 coefficients into 9 bytes)
/// For gamma1_bits=19: packs 20 bits per coefficient (2 coefficients into 5 bytes)
/// Assumes coefficients are normalized.
fn polyPackLeGamma1(p: Poly, comptime gamma1_bits: u8, buf: []u8) void {
    const gamma1: u32 = @as(u32, 1) << gamma1_bits;

    if (gamma1_bits == 17) {
        // Pack 4 coefficients into 9 bytes (18 bits each)
        var j: usize = 0;
        var i: usize = 0;
        while (i < buf.len) : (i += 9) {
            // Convert from [0,γ₁] ∪ (Q-γ₁, Q) to [0, 2γ₁)
            const p0 = centeredToPositive(p.cs[j], gamma1);
            const p1 = centeredToPositive(p.cs[j + 1], gamma1);
            const p2 = centeredToPositive(p.cs[j + 2], gamma1);
            const p3 = centeredToPositive(p.cs[j + 3], gamma1);

            buf[i] = @truncate(p0);
            buf[i + 1] = @truncate(p0 >> 8);
            buf[i + 2] = @truncate((p0 >> 16) | (p1 << 2));
            buf[i + 3] = @truncate(p1 >> 6);
            buf[i + 4] = @truncate((p1 >> 14) | (p2 << 4));
            buf[i + 5] = @truncate(p2 >> 4);
            buf[i + 6] = @truncate((p2 >> 12) | (p3 << 6));
            buf[i + 7] = @truncate(p3 >> 2);
            buf[i + 8] = @truncate(p3 >> 10);

            j += 4;
        }
    } else if (gamma1_bits == 19) {
        // Pack 2 coefficients into 5 bytes (20 bits each)
        var j: usize = 0;
        var i: usize = 0;
        while (i < buf.len) : (i += 5) {
            const p0 = centeredToPositive(p.cs[j], gamma1);
            const p1 = centeredToPositive(p.cs[j + 1], gamma1);

            buf[i] = @truncate(p0);
            buf[i + 1] = @truncate(p0 >> 8);
            buf[i + 2] = @truncate((p0 >> 16) | (p1 << 4));
            buf[i + 3] = @truncate(p1 >> 4);
            buf[i + 4] = @truncate(p1 >> 12);

            j += 2;
        }
    } else {
        @compileError("gamma1_bits must be 17 or 19");
    }
}

/// Unpack polynomial with coefficients in (-gamma1, gamma1] from bytes.
/// Output coefficients will be normalized.
fn polyUnpackLeGamma1(comptime gamma1_bits: u8, buf: []const u8) Poly {
    const gamma1: u32 = @as(u32, 1) << gamma1_bits;
    var p = Poly.zero;

    if (gamma1_bits == 17) {
        // Unpack 4 coefficients from 9 bytes (18 bits each)
        var j: usize = 0;
        var i: usize = 0;
        while (i < buf.len) : (i += 9) {
            var p0 = @as(u32, buf[i]) | (@as(u32, buf[i + 1]) << 8) | ((@as(u32, buf[i + 2]) & 0x3) << 16);
            var p1 = (@as(u32, buf[i + 2]) >> 2) | (@as(u32, buf[i + 3]) << 6) | ((@as(u32, buf[i + 4]) & 0xf) << 14);
            var p2 = (@as(u32, buf[i + 4]) >> 4) | (@as(u32, buf[i + 5]) << 4) | ((@as(u32, buf[i + 6]) & 0x3f) << 12);
            var p3 = (@as(u32, buf[i + 6]) >> 6) | (@as(u32, buf[i + 7]) << 2) | (@as(u32, buf[i + 8]) << 10);

            // Convert from [0, 2γ₁) to (-γ₁, γ₁]
            p0 = centeredToPositive(p0, gamma1);
            p1 = centeredToPositive(p1, gamma1);
            p2 = centeredToPositive(p2, gamma1);
            p3 = centeredToPositive(p3, gamma1);

            p.cs[j] = p0;
            p.cs[j + 1] = p1;
            p.cs[j + 2] = p2;
            p.cs[j + 3] = p3;

            j += 4;
        }
    } else if (gamma1_bits == 19) {
        // Unpack 2 coefficients from 5 bytes (20 bits each)
        var j: usize = 0;
        var i: usize = 0;
        while (i < buf.len) : (i += 5) {
            var p0 = @as(u32, buf[i]) | (@as(u32, buf[i + 1]) << 8) | ((@as(u32, buf[i + 2]) & 0xf) << 16);
            var p1 = (@as(u32, buf[i + 2]) >> 4) | (@as(u32, buf[i + 3]) << 4) | (@as(u32, buf[i + 4]) << 12);

            p0 = centeredToPositive(p0, gamma1);
            p1 = centeredToPositive(p1, gamma1);

            p.cs[j] = p0;
            p.cs[j + 1] = p1;

            j += 2;
        }
    } else {
        @compileError("gamma1_bits must be 17 or 19");
    }

    return p;
}

/// Pack W1 polynomial for verification.
/// For gamma1_bits=17: packs 6 bits per coefficient (4 coefficients into 3 bytes)
/// For gamma1_bits=19: packs 4 bits per coefficient (2 coefficients into 1 byte)
/// Assumes coefficients are normalized.
fn polyPackW1(p: Poly, comptime gamma1_bits: u8, buf: []u8) void {
    if (gamma1_bits == 17) {
        // Pack 4 coefficients into 3 bytes (6 bits each)
        var j: usize = 0;
        var i: usize = 0;
        while (i < buf.len) : (i += 3) {
            buf[i] = @truncate(p.cs[j] | (p.cs[j + 1] << 6));
            buf[i + 1] = @truncate((p.cs[j + 1] >> 2) | (p.cs[j + 2] << 4));
            buf[i + 2] = @truncate((p.cs[j + 2] >> 4) | (p.cs[j + 3] << 2));
            j += 4;
        }
    } else if (gamma1_bits == 19) {
        // Pack 2 coefficients into 1 byte (4 bits each) - equivalent to packLe16
        var j: usize = 0;
        for (0..buf.len) |i| {
            buf[i] = @truncate(p.cs[j] | (p.cs[j + 1] << 4));
            j += 2;
        }
    } else {
        @compileError("gamma1_bits must be 17 or 19");
    }
}

fn polyDeriveUniform(seed: *const [32]u8, nonce: u16) Poly {
    var domain_sep: [2]u8 = undefined;
    domain_sep[0] = @truncate(nonce);
    domain_sep[1] = @truncate(nonce >> 8);

    return sampleUniformRejection(
        Poly,
        Q,
        23,
        N,
        seed,
        &domain_sep,
    );
}

/// Sample p uniformly with coefficients of norm less than or equal to η,
/// using the given seed and nonce with SHAKE-256.
/// The polynomial will not be normalized, but will have coefficients in [q-η, q+η].
/// FIPS 204: ExpandS (Algorithm 27)
fn expandS(comptime eta: u8, seed: *const [64]u8, nonce: u16) Poly {
    comptime {
        if (eta != 2 and eta != 4) {
            @compileError("eta must be 2 or 4");
        }
    }

    var p = Poly.zero;
    var i: usize = 0;

    var buf: [sha3.Shake256.block_length]u8 = undefined; // SHAKE-256 rate is 136 bytes

    // Prepare input: seed || nonce (little-endian u16)
    var input: [66]u8 = undefined;
    @memcpy(input[0..64], seed);
    input[64] = @truncate(nonce);
    input[65] = @truncate(nonce >> 8);

    var h = sha3.Shake256.init(.{});
    h.update(&input);

    while (i < N) {
        h.squeeze(&buf);

        // Process buffer: extract two samples per byte (4-bit nibbles)
        var j: usize = 0;
        while (j < buf.len and i < N) : (j += 1) {
            var t1 = @as(u32, buf[j]) & 15;
            var t2 = @as(u32, buf[j]) >> 4;

            if (eta == 2) {
                // For eta=2: reject if t > 14, then reduce mod 5
                if (t1 <= 14) {
                    t1 -%= ((205 * t1) >> 10) * 5; // reduce mod 5
                    p.cs[i] = Q + eta - t1;
                    i += 1;
                }
                if (t2 <= 14 and i < N) {
                    t2 -%= ((205 * t2) >> 10) * 5; // reduce mod 5
                    p.cs[i] = Q + eta - t2;
                    i += 1;
                }
            } else if (eta == 4) {
                // For eta=4: accept if t <= 2*eta = 8
                if (t1 <= 2 * eta) {
                    p.cs[i] = Q + eta - t1;
                    i += 1;
                }
                if (t2 <= 2 * eta and i < N) {
                    p.cs[i] = Q + eta - t2;
                    i += 1;
                }
            }
        }
    }

    return p;
}

/// Sample p uniformly with τ non-zero coefficients in {Q-1, 1} using SHAKE-256.
/// This creates a "ball" polynomial with exactly tau non-zero ±1 coefficients.
/// The polynomial will be normalized with coefficients in {0, 1, Q-1}.
/// FIPS 204: SampleInBall (Algorithm 18)
fn sampleInBall(comptime tau: u16, seed: []const u8) Poly {
    var p = Poly.zero;

    var buf: [sha3.Shake256.block_length]u8 = undefined; // SHAKE-256 rate is 136 bytes

    var h = sha3.Shake256.init(.{});
    h.update(seed);
    h.squeeze(&buf);

    // Extract signs from first 8 bytes
    var signs: u64 = 0;
    for (0..8) |j| {
        signs |= @as(u64, buf[j]) << @intCast(j * 8);
    }
    var buf_off: usize = 8;

    // Generate tau non-zero coefficients using Fisher-Yates shuffle
    // Start with N-tau zeros, then add tau ±1 values
    var i: u16 = N - tau;
    while (i < N) : (i += 1) {
        var b: u16 = undefined;

        // Find location using rejection sampling
        while (true) {
            if (buf_off >= buf.len) {
                h.squeeze(&buf);
                buf_off = 0;
            }

            b = buf[buf_off];
            buf_off += 1;

            if (b <= i) {
                break;
            }
        }

        // Shuffle: move existing value to position i
        p.cs[i] = p.cs[b];

        // Set position b to ±1 based on sign bit
        p.cs[b] = 1;
        const sign_bit: u1 = @truncate(signs);
        const mask = bitMask(u32, sign_bit);
        p.cs[b] ^= mask & (1 | (Q - 1));
        signs >>= 1;
    }

    return p;
}

/// Sample a polynomial with coefficients uniformly distributed in (-gamma1, gamma1]
/// Used for sampling the masking vector y during signing
/// FIPS 204: ExpandMask (Algorithm 28)
fn expandMask(comptime gamma1_bits: u8, seed: *const [64]u8, nonce: u16) Poly {
    const packed_size = ((gamma1_bits + 1) * N) / 8;
    var buf: [packed_size]u8 = undefined;

    // Construct IV: seed || nonce (little-endian)
    var iv: [66]u8 = undefined;
    @memcpy(iv[0..64], seed);
    iv[64] = @truncate(nonce & 0xFF);
    iv[65] = @truncate(nonce >> 8);

    var h = sha3.Shake256.init(.{});
    h.update(&iv);
    h.squeeze(&buf);

    // Unpack the polynomial
    return polyUnpackLeGamma1(gamma1_bits, &buf);
}

fn MLDSAImpl(comptime p: Params) type {
    return struct {
        pub const params = p;
        pub const name = p.name;
        pub const gamma1: u32 = @as(u32, 1) << p.gamma1_bits;
        pub const beta: u32 = p.tau * p.eta;
        pub const alpha: u32 = 2 * p.gamma2;

        const Self = @This();
        const PolyVecL = PolyVec(p.l);
        const PolyVecK = PolyVec(p.k);
        const MatKxL = Mat(p.k, p.l);

        /// Length of the seed used for deterministic key generation (32 bytes).
        pub const seed_length: usize = 32;

        /// Length (in bytes) of optional random bytes, for non-deterministic signatures.
        pub const noise_length = 32;

        /// Size of an encoded public key in bytes.
        pub const public_key_bytes: usize = 32 + polyT1PackedSize() * p.k;

        /// Size of an encoded secret key in bytes.
        pub const private_key_bytes: usize = 32 + 32 + p.tr_size +
            polyLeqEtaPackedSize() * (p.l + p.k) + polyT0PackedSize() * p.k;

        /// Size of an encoded signature in bytes.
        pub const signature_bytes: usize = p.ctilde_size +
            polyLeGamma1PackedSize() * p.l + p.omega + p.k;

        // Packed sizes for different polynomial representations
        fn polyLeqEtaPackedSize() usize {
            // For eta=2: 3 bits per coefficient (values in [0,4])
            // For eta=4: 4 bits per coefficient (values in [0,8])
            const double_eta_bits = if (p.eta == 2) 3 else 4;
            return (N * double_eta_bits) / 8;
        }

        fn polyLeGamma1PackedSize() usize {
            return ((p.gamma1_bits + 1) * N) / 8;
        }

        fn polyT1PackedSize() usize {
            return (N * (Q_BITS - D)) / 8;
        }

        fn polyT0PackedSize() usize {
            return (N * D) / 8;
        }

        fn polyW1PackedSize() usize {
            return (N * (Q_BITS - p.gamma1_bits)) / 8;
        }

        /// Helper function to compute CRH (Collision Resistant Hash) using SHAKE-256.
        /// This consolidates the repeated pattern of init-update-squeeze for hash operations.
        fn crh(comptime outsize: usize, inputs: anytype) [outsize]u8 {
            var h = sha3.Shake256.init(.{});
            inline for (inputs) |input| {
                h.update(input);
            }
            var out: [outsize]u8 = undefined;
            h.squeeze(&out);
            return out;
        }

        /// Helper function to compute t = As1 + s2.
        /// This is used during key generation and public key reconstruction.
        fn computeT(A: MatKxL, s1_hat: PolyVecL, s2: PolyVecK) PolyVecK {
            const t = A.mulVec(s1_hat).add(s2);
            return t.normalize();
        }

        /// ML-DSA public key
        pub const PublicKey = struct {
            /// Size of the encoded public key in bytes
            pub const encoded_length: usize = 32 + polyT1PackedSize() * p.k;

            rho: [32]u8, // Seed for matrix A
            t1: PolyVecK, // High bits of t = As1 + s2

            // Cached values
            t1_packed: [polyT1PackedSize() * p.k]u8,
            A: MatKxL,
            tr: [p.tr_size]u8, // CRH(rho || t1)

            /// Encode public key to bytes
            pub fn toBytes(self: PublicKey) [encoded_length]u8 {
                var out: [encoded_length]u8 = undefined;
                @memcpy(out[0..32], &self.rho);
                @memcpy(out[32..], &self.t1_packed);
                return out;
            }

            /// Decode public key from bytes
            pub fn fromBytes(bytes: [encoded_length]u8) !PublicKey {
                var pk: PublicKey = undefined;
                @memcpy(&pk.rho, bytes[0..32]);
                @memcpy(&pk.t1_packed, bytes[32..]);

                pk.t1 = PolyVecK.unpackT1(pk.t1_packed[0..]);
                pk.A = MatKxL.derive(&pk.rho);
                pk.tr = crh(p.tr_size, .{&bytes});

                return pk;
            }
        };

        /// ML-DSA secret key
        pub const SecretKey = struct {
            /// Size of the encoded secret key in bytes
            pub const encoded_length: usize = 32 + 32 + p.tr_size +
                polyLeqEtaPackedSize() * (p.l + p.k) + polyT0PackedSize() * p.k;

            rho: [32]u8, // Seed for matrix A
            key: [32]u8, // Seed for signature generation randomness
            tr: [p.tr_size]u8, // CRH(rho || t1)
            s1: PolyVecL, // Secret vector 1
            s2: PolyVecK, // Secret vector 2
            t0: PolyVecK, // Low bits of t = As1 + s2

            // Cached values (in NTT domain)
            A: MatKxL,
            s1_hat: PolyVecL,
            s2_hat: PolyVecK,
            t0_hat: PolyVecK,

            /// Encode secret key to bytes
            pub fn toBytes(self: SecretKey) [encoded_length]u8 {
                var out: [encoded_length]u8 = undefined;
                var offset: usize = 0;

                @memcpy(out[offset .. offset + 32], &self.rho);
                offset += 32;

                @memcpy(out[offset .. offset + 32], &self.key);
                offset += 32;

                @memcpy(out[offset .. offset + p.tr_size], &self.tr);
                offset += p.tr_size;

                if (p.eta == 2) {
                    self.s1.packLeqEta(2, out[offset..][0 .. p.l * polyLeqEtaPackedSize()]);
                } else {
                    self.s1.packLeqEta(4, out[offset..][0 .. p.l * polyLeqEtaPackedSize()]);
                }
                offset += p.l * polyLeqEtaPackedSize();

                if (p.eta == 2) {
                    self.s2.packLeqEta(2, out[offset..][0 .. p.k * polyLeqEtaPackedSize()]);
                } else {
                    self.s2.packLeqEta(4, out[offset..][0 .. p.k * polyLeqEtaPackedSize()]);
                }
                offset += p.k * polyLeqEtaPackedSize();

                self.t0.packT0(out[offset..][0 .. p.k * polyT0PackedSize()]);
                offset += p.k * polyT0PackedSize();

                return out;
            }

            /// Decode secret key from bytes
            pub fn fromBytes(bytes: [encoded_length]u8) !SecretKey {
                var sk: SecretKey = undefined;
                var offset: usize = 0;

                @memcpy(&sk.rho, bytes[offset .. offset + 32]);
                offset += 32;

                @memcpy(&sk.key, bytes[offset .. offset + 32]);
                offset += 32;

                @memcpy(&sk.tr, bytes[offset .. offset + p.tr_size]);
                offset += p.tr_size;

                sk.s1 = if (p.eta == 2)
                    PolyVecL.unpackLeqEta(2, bytes[offset..][0 .. p.l * polyLeqEtaPackedSize()])
                else
                    PolyVecL.unpackLeqEta(4, bytes[offset..][0 .. p.l * polyLeqEtaPackedSize()]);
                offset += p.l * polyLeqEtaPackedSize();

                sk.s2 = if (p.eta == 2)
                    PolyVecK.unpackLeqEta(2, bytes[offset..][0 .. p.k * polyLeqEtaPackedSize()])
                else
                    PolyVecK.unpackLeqEta(4, bytes[offset..][0 .. p.k * polyLeqEtaPackedSize()]);
                offset += p.k * polyLeqEtaPackedSize();

                sk.t0 = PolyVecK.unpackT0(bytes[offset..][0 .. p.k * polyT0PackedSize()]);
                offset += p.k * polyT0PackedSize();

                // Compute cached NTT values for efficient signing
                sk.A = MatKxL.derive(&sk.rho);
                sk.s1_hat = sk.s1.ntt();
                sk.s2_hat = sk.s2.ntt();
                sk.t0_hat = sk.t0.ntt();

                return sk;
            }

            /// Compute the public key from this private key
            pub fn public(self: *const SecretKey) PublicKey {
                var pk: PublicKey = undefined;
                pk.rho = self.rho;
                pk.A = self.A;
                pk.tr = self.tr;

                // Reconstruct t = As1 + s2, then extract high bits t1
                // Using power2Round: t = t1 * 2^D + t0
                const t = computeT(self.A, self.s1_hat, self.s2);

                var t0_unused: PolyVecK = undefined;
                pk.t1 = t.power2Round(&t0_unused);
                pk.t1.packT1(&pk.t1_packed);

                return pk;
            }

            /// Create a Signer for incrementally signing a message.
            /// The noise parameter can be null for deterministic signatures,
            /// or provide randomness for hedged signatures (recommended for fault attack resistance).
            pub fn signer(self: *const SecretKey, noise: ?[noise_length]u8) !Signer {
                return self.signerWithContext(noise, "");
            }

            /// Create a Signer for incrementally signing a message with context.
            /// The noise parameter can be null for deterministic signatures,
            /// or provide randomness for hedged signatures (recommended for fault attack resistance).
            /// The context parameter is an optional context string (max 255 bytes).
            pub fn signerWithContext(self: *const SecretKey, noise: ?[noise_length]u8, context: []const u8) ContextTooLongError!Signer {
                return Signer.init(self, noise, context);
            }
        };

        /// Generate a new key pair from a seed (deterministic)
        pub fn newKeyFromSeed(seed: *const [seed_length]u8) struct { pk: PublicKey, sk: SecretKey } {
            var sk: SecretKey = undefined;
            var pk: PublicKey = undefined;

            // NIST mode: expand seed || k || l using SHAKE-256 to get 128-byte expanded seed
            const e_seed = crh(128, .{ seed, &[_]u8{ p.k, p.l } });

            @memcpy(&pk.rho, e_seed[0..32]);
            const s_seed = e_seed[32..96];
            @memcpy(&sk.key, e_seed[96..128]);
            @memcpy(&sk.rho, &pk.rho);

            sk.A = MatKxL.derive(&pk.rho);
            pk.A = sk.A;

            const s_seed_array: *const [64]u8 = s_seed[0..64];
            for (0..p.l) |i| {
                sk.s1.ps[i] = expandS(p.eta, s_seed_array, @intCast(i));
            }

            for (0..p.k) |i| {
                sk.s2.ps[i] = expandS(p.eta, s_seed_array, @intCast(p.l + i));
            }

            sk.s1_hat = sk.s1.ntt();
            sk.s2_hat = sk.s2.ntt();

            const t = computeT(sk.A, sk.s1_hat, sk.s2);

            pk.t1 = t.power2Round(&sk.t0);
            sk.t0_hat = sk.t0.ntt();
            pk.t1.packT1(&pk.t1_packed);

            // tr = H(pk) = H(rho || t1)
            const pk_bytes = pk.toBytes();
            const tr = crh(p.tr_size, .{&pk_bytes});
            sk.tr = tr;
            pk.tr = tr;

            return .{ .pk = pk, .sk = sk };
        }

        /// ML-DSA signature
        pub const Signature = struct {
            /// Size of the encoded signature in bytes
            pub const encoded_length: usize = p.ctilde_size +
                polyLeGamma1PackedSize() * p.l + p.omega + p.k;

            c_tilde: [p.ctilde_size]u8, // Challenge hash
            z: PolyVecL, // Response vector
            hint: PolyVecK, // Hint vector

            /// Encode signature to bytes
            pub fn toBytes(self: Signature) [encoded_length]u8 {
                var out: [encoded_length]u8 = undefined;
                var offset: usize = 0;

                @memcpy(out[offset .. offset + p.ctilde_size], &self.c_tilde);
                offset += p.ctilde_size;

                self.z.packLeGamma1(p.gamma1_bits, out[offset .. offset + polyLeGamma1PackedSize() * p.l]);
                offset += polyLeGamma1PackedSize() * p.l;

                _ = self.hint.packHint(p.omega, out[offset..]);

                return out;
            }

            /// Decode signature from bytes
            pub fn fromBytes(bytes: [encoded_length]u8) EncodingError!Signature {
                var sig: Signature = undefined;
                var offset: usize = 0;

                @memcpy(&sig.c_tilde, bytes[offset .. offset + p.ctilde_size]);
                offset += p.ctilde_size;

                sig.z = PolyVecL.unpackLeGamma1(p.gamma1_bits, bytes[offset .. offset + polyLeGamma1PackedSize() * p.l]);
                offset += polyLeGamma1PackedSize() * p.l;

                // Validate ||z||_inf < gamma1 - beta per FIPS 204
                if (sig.z.exceeds(gamma1 - beta)) {
                    return error.InvalidEncoding;
                }

                sig.hint = PolyVecK.unpackHint(p.omega, bytes[offset..]) orelse return error.InvalidEncoding;

                return sig;
            }

            pub const VerifyError = Verifier.InitError || Verifier.VerifyError;

            /// Verify this signature against a message and public key.
            /// Returns an error if the signature is invalid.
            pub fn verify(
                sig: Signature,
                msg: []const u8,
                public_key: PublicKey,
            ) VerifyError!void {
                return sig.verifyWithContext(msg, public_key, "");
            }

            /// Verify this signature against a message and public key with context.
            /// Returns an error if the signature is invalid.
            /// The context parameter is an optional context string (max 255 bytes).
            pub fn verifyWithContext(
                sig: Signature,
                msg: []const u8,
                public_key: PublicKey,
                context: []const u8,
            ) VerifyError!void {
                if (context.len > 255) {
                    return error.SignatureVerificationFailed;
                }

                var h = sha3.Shake256.init(.{});
                h.update(&public_key.tr);
                h.update(&[_]u8{0}); // Domain separator: 0 for pure ML-DSA
                h.update(&[_]u8{@intCast(context.len)});
                if (context.len > 0) {
                    h.update(context);
                }
                h.update(msg);
                var mu: [64]u8 = undefined;
                h.squeeze(&mu);

                const z_hat = sig.z.ntt();
                const Az = public_key.A.mulVecHat(z_hat);

                // Compute w' ≈ Az - 2^d·c·t1 (approximate w used in signing)
                var Az2dct1 = public_key.t1.mulBy2toD();
                Az2dct1 = Az2dct1.ntt();
                const c_poly = sampleInBall(p.tau, &sig.c_tilde);
                const c_hat = c_poly.ntt();
                for (0..p.k) |i| {
                    Az2dct1.ps[i] = Az2dct1.ps[i].mulHat(c_hat);
                }
                Az2dct1 = Az.sub(Az2dct1);
                Az2dct1 = Az2dct1.reduceLe2Q();
                Az2dct1 = Az2dct1.invNTT();
                Az2dct1 = Az2dct1.normalizeAssumingLe2Q();

                // Apply hints to recover high bits w1'
                var w1_prime = Az2dct1.useHint(sig.hint, p.gamma2);
                var w1_packed: [polyW1PackedSize() * p.k]u8 = undefined;
                w1_prime.packW1(p.gamma1_bits, &w1_packed);

                const c_prime = crh(p.ctilde_size, .{ &mu, &w1_packed });

                if (!mem.eql(u8, &c_prime, &sig.c_tilde)) {
                    return error.SignatureVerificationFailed;
                }
            }

            /// Create a Verifier for incrementally verifying a signature.
            pub fn verifier(self: Signature, public_key: PublicKey) !Verifier {
                return self.verifierWithContext(public_key, "");
            }

            /// Create a Verifier for incrementally verifying a signature with context.
            /// The context parameter is an optional context string (max 255 bytes).
            pub fn verifierWithContext(self: Signature, public_key: PublicKey, context: []const u8) ContextTooLongError!Verifier {
                return Verifier.init(self, public_key, context);
            }
        };

        /// A Signer is used to incrementally compute a signature over a streamed message.
        /// It can be obtained from a `SecretKey` or `KeyPair`, using the `signer()` function.
        pub const Signer = struct {
            h: sha3.Shake256, // For computing μ = CRH(tr || msg)
            secret_key: *const SecretKey,
            rnd: [32]u8,

            /// Initialize a new Signer.
            /// The noise parameter can be null for deterministic signatures,
            /// or provide randomness for hedged signatures (recommended for fault attack resistance).
            /// The context parameter is an optional context string (max 255 bytes).
            pub fn init(secret_key: *const SecretKey, noise: ?[noise_length]u8, context: []const u8) ContextTooLongError!Signer {
                if (context.len > 255) {
                    return error.ContextTooLong;
                }

                var h = sha3.Shake256.init(.{});
                h.update(&secret_key.tr);
                h.update(&[_]u8{0}); // Domain separator: 0 for pure ML-DSA
                h.update(&[_]u8{@intCast(context.len)});
                if (context.len > 0) {
                    h.update(context);
                }

                return Signer{
                    .h = h,
                    .secret_key = secret_key,
                    .rnd = noise orelse @splat(0),
                };
            }

            /// Add new data to the message being signed.
            pub fn update(self: *Signer, data: []const u8) void {
                self.h.update(data);
            }

            /// Compute a signature over the entire message.
            pub fn finalize(self: *Signer) Signature {
                var mu: [64]u8 = undefined;
                self.h.squeeze(&mu);

                const rho_prime = crh(64, .{ &self.secret_key.key, &self.rnd, &mu });

                var sig: Signature = undefined;
                var y_nonce: u16 = 0;

                // Rejection sampling loop (FIPS 204 Algorithm 2, steps 5-16)
                var attempt: u32 = 0;
                while (true) {
                    attempt += 1;
                    if (attempt >= 576) { // (6/7)⁵⁷⁶ < 2⁻¹²⁸
                        @branchHint(.unlikely);
                        unreachable;
                    }

                    const y = PolyVecL.deriveUniformLeGamma1(p.gamma1_bits, &rho_prime, y_nonce);
                    y_nonce += @intCast(p.l);

                    const y_hat = y.ntt();
                    var w = self.secret_key.A.mulVec(y_hat);

                    w = w.normalize();
                    var w0: PolyVecK = undefined;
                    const w1 = w.decomposeVec(p.gamma2, &w0);
                    var w1_packed: [polyW1PackedSize() * p.k]u8 = undefined;
                    w1.packW1(p.gamma1_bits, &w1_packed);

                    sig.c_tilde = crh(p.ctilde_size, .{ &mu, &w1_packed });

                    const c_poly = sampleInBall(p.tau, &sig.c_tilde);
                    const c_hat = c_poly.ntt();

                    // Rejection check: ensure masking is effective
                    var w0mcs2: PolyVecK = undefined;
                    for (0..p.k) |i| {
                        w0mcs2.ps[i] = c_hat.mulHat(self.secret_key.s2_hat.ps[i]);
                        w0mcs2.ps[i] = w0mcs2.ps[i].invNTT();
                    }
                    w0mcs2 = w0.sub(w0mcs2);
                    w0mcs2 = w0mcs2.normalize();

                    if (w0mcs2.exceeds(p.gamma2 - beta)) {
                        continue;
                    }

                    // Compute response z = y + c·s1
                    for (0..p.l) |i| {
                        sig.z.ps[i] = c_hat.mulHat(self.secret_key.s1_hat.ps[i]);
                        sig.z.ps[i] = sig.z.ps[i].invNTT();
                    }
                    sig.z = sig.z.add(y);
                    sig.z = sig.z.normalize();

                    if (sig.z.exceeds(gamma1 - beta)) {
                        continue;
                    }

                    var ct0: PolyVecK = undefined;
                    for (0..p.k) |i| {
                        ct0.ps[i] = c_hat.mulHat(self.secret_key.t0_hat.ps[i]);
                        ct0.ps[i] = ct0.ps[i].invNTT();
                    }
                    ct0 = ct0.reduceLe2Q();
                    ct0 = ct0.normalize();

                    if (ct0.exceeds(p.gamma2)) {
                        continue;
                    }

                    // Generate hints for verification
                    var w0mcs2pct0 = w0mcs2.add(ct0);
                    w0mcs2pct0 = w0mcs2pct0.reduceLe2Q();
                    w0mcs2pct0 = w0mcs2pct0.normalizeAssumingLe2Q();
                    const hint_result = PolyVecK.makeHintVec(w0mcs2pct0, w1, p.gamma2);
                    if (hint_result.pop > p.omega) {
                        continue;
                    }
                    sig.hint = hint_result.hint;

                    return sig;
                }
            }
        };

        /// A Verifier is used to incrementally verify a signature over a streamed message.
        /// It can be obtained from a `Signature`, using the `verifier()` function.
        pub const Verifier = struct {
            h: sha3.Shake256, // For computing μ = CRH(tr || msg)
            signature: Signature,
            public_key: PublicKey,

            pub const InitError = EncodingError;
            pub const VerifyError = SignatureVerificationError;

            /// Initialize a new Verifier.
            /// The context parameter is an optional context string (max 255 bytes).
            pub fn init(signature: Signature, public_key: PublicKey, context: []const u8) ContextTooLongError!Verifier {
                if (context.len > 255) {
                    return error.ContextTooLong;
                }

                var h = sha3.Shake256.init(.{});
                h.update(&public_key.tr);
                h.update(&[_]u8{0}); // Domain separator: 0 for pure ML-DSA
                h.update(&[_]u8{@intCast(context.len)}); // Context length
                if (context.len > 0) {
                    h.update(context);
                }

                return Verifier{
                    .h = h,
                    .signature = signature,
                    .public_key = public_key,
                };
            }

            /// Add new content to the message to be verified.
            pub fn update(self: *Verifier, data: []const u8) void {
                self.h.update(data);
            }

            /// Verify that the signature is valid for the entire message.
            pub fn verify(self: *Verifier) SignatureVerificationError!void {
                var mu: [64]u8 = undefined;
                self.h.squeeze(&mu);

                const z_hat = self.signature.z.ntt();
                const Az = self.public_key.A.mulVecHat(z_hat);

                // Compute w' ≈ Az - 2^d·c·t1 (approximate w used in signing)
                var Az2dct1 = self.public_key.t1.mulBy2toD();
                Az2dct1 = Az2dct1.ntt();
                const c_poly = sampleInBall(p.tau, &self.signature.c_tilde);
                const c_hat = c_poly.ntt();
                for (0..p.k) |i| {
                    Az2dct1.ps[i] = Az2dct1.ps[i].mulHat(c_hat);
                }
                Az2dct1 = Az.sub(Az2dct1);
                Az2dct1 = Az2dct1.reduceLe2Q();
                Az2dct1 = Az2dct1.invNTT();
                Az2dct1 = Az2dct1.normalizeAssumingLe2Q();

                // Apply hints to recover high bits w1'
                var w1_prime = Az2dct1.useHint(self.signature.hint, p.gamma2);
                var w1_packed: [polyW1PackedSize() * p.k]u8 = undefined;
                w1_prime.packW1(p.gamma1_bits, &w1_packed);

                const c_prime = crh(p.ctilde_size, .{ &mu, &w1_packed });

                if (!mem.eql(u8, &c_prime, &self.signature.c_tilde)) {
                    return error.SignatureVerificationFailed;
                }
            }
        };

        /// A key pair consisting of a secret key and its corresponding public key.
        pub const KeyPair = struct {
            /// Length (in bytes) of a seed required to create a key pair.
            pub const seed_length = Self.seed_length;

            /// The public key component.
            public_key: PublicKey,

            /// The secret key component.
            secret_key: SecretKey,

            /// Generate a new random key pair.
            pub fn generate(io: std.Io) KeyPair {
                var seed: [Self.seed_length]u8 = undefined;
                io.random(&seed);
                return generateDeterministic(seed) catch unreachable;
            }

            /// Generate a key pair deterministically from a seed.
            /// Use for testing or when reproducibility is required.
            /// The seed should be generated using a cryptographically secure random source.
            pub fn generateDeterministic(seed: [32]u8) !KeyPair {
                const keys = newKeyFromSeed(&seed);
                return .{
                    .public_key = keys.pk,
                    .secret_key = keys.sk,
                };
            }

            /// Derive the public key from an existing secret key.
            /// This recomputes the public key components from the secret key.
            pub fn fromSecretKey(sk: SecretKey) !KeyPair {
                var pk: PublicKey = undefined;
                pk.rho = sk.rho;
                pk.tr = sk.tr;
                pk.A = sk.A;

                const t = computeT(sk.A, sk.s1_hat, sk.s2);

                var t0: PolyVecK = undefined;
                pk.t1 = t.power2Round(&t0);
                pk.t1.packT1(&pk.t1_packed);

                return .{
                    .public_key = pk,
                    .secret_key = sk,
                };
            }

            /// Create a Signer for incrementally signing a message.
            /// The noise parameter can be null for deterministic signatures,
            /// or provide randomness for hedged signatures (recommended for fault attack resistance).
            pub fn signer(self: *const KeyPair, noise: ?[noise_length]u8) !Signer {
                return self.secret_key.signer(noise);
            }

            /// Create a Signer for incrementally signing a message with context.
            /// The noise parameter can be null for deterministic signatures,
            /// or provide randomness for hedged signatures (recommended for fault attack resistance).
            /// The context parameter is an optional context string (max 255 bytes).
            pub fn signerWithContext(self: *const KeyPair, noise: ?[noise_length]u8, context: []const u8) ContextTooLongError!Signer {
                return self.secret_key.signerWithContext(noise, context);
            }

            /// Sign a message using this key pair.
            /// The noise parameter can be null for deterministic signatures,
            /// or provide randomness for hedged signatures (recommended for fault attack resistance).
            pub fn sign(
                kp: KeyPair,
                msg: []const u8,
                noise: ?[noise_length]u8,
            ) !Signature {
                return kp.signWithContext(msg, noise, "");
            }

            /// Sign a message using this key pair with context.
            /// The noise parameter can be null for deterministic signatures,
            /// or provide randomness for hedged signatures (recommended for fault attack resistance).
            /// The context parameter is an optional context string (max 255 bytes).
            pub fn signWithContext(
                kp: KeyPair,
                msg: []const u8,
                noise: ?[noise_length]u8,
                context: []const u8,
            ) ContextTooLongError!Signature {
                var st = try kp.signerWithContext(noise, context);
                st.update(msg);
                return st.finalize();
            }
        };
    };
}

test "modular arithmetic" {
    // Test Montgomery reduction
    const x: u64 = 12345678;
    const y = montReduceLe2Q(x);
    try testing.expect(y < 2 * Q);

    // Test modQ
    try testing.expectEqual(@as(u32, 0), modQ(Q));
    try testing.expectEqual(@as(u32, 1), modQ(Q + 1));
}

test "polynomial operations" {
    var p1 = Poly.zero;
    p1.cs[0] = 1;
    p1.cs[1] = 2;

    var p2 = Poly.zero;
    p2.cs[0] = 3;
    p2.cs[1] = 4;

    const p3 = p1.add(p2);
    try testing.expectEqual(@as(u32, 4), p3.cs[0]);
    try testing.expectEqual(@as(u32, 6), p3.cs[1]);
}

test "NTT and inverse NTT" {
    // Create a test polynomial in REGULAR FORM (not Montgomery)
    var p = Poly.zero;
    for (0..N) |i| {
        p.cs[i] = @intCast(i % Q);
    }

    // Apply NTT then inverse NTT
    // According to Dilithium spec: NTT followed by invNTT multiplies by R
    // So result will be p * R (i.e., p in Montgomery form)
    var p_ntt = p.ntt();

    // Reduce before invNTT (as Go test does)
    p_ntt = p_ntt.reduceLe2Q();

    const p_restored = p_ntt.invNTT();

    // Reduce and normalize
    const p_reduced = p_restored.reduceLe2Q();
    const p_norm = p_reduced.normalize();

    // Check if we get p * R (which equals toMont(p))
    for (0..N) |i| {
        const original: u32 = @intCast(i % Q);
        const expected = toMont(original);
        const expected_norm = modQ(expected);
        try testing.expectEqual(expected_norm, p_norm.cs[i]);
    }
}

test "parameter set instantiation" {
    // Just verify we can instantiate all three parameter sets
    const ml44 = MLDSA44;
    const ml65 = MLDSA65;
    const ml87 = MLDSA87;

    try testing.expectEqualStrings("ML-DSA-44", ml44.name);
    try testing.expectEqualStrings("ML-DSA-65", ml65.name);
    try testing.expectEqualStrings("ML-DSA-87", ml87.name);
}

test "compare zetas with Go implementation" {
    // First 16 zetas from Go implementation (in Montgomery form)
    const go_zetas = [16]u32{
        4193792, 25847,   5771523, 7861508, 237124,  7602457, 7504169,
        466468,  1826347, 2353451, 8021166, 6288512, 3119733, 5495562,
        3111497, 2680103,
    };

    // Compare our computed zetas with Go's
    for (0..16) |i| {
        try testing.expectEqual(go_zetas[i], zetas[i]);
    }
}

test "NTT with simple polynomial" {
    // Test with a very simple polynomial: just one coefficient set to 1 in regular form
    var p = Poly.zero;
    p.cs[0] = 1;

    var p_ntt = p.ntt();

    // Reduce before invNTT (as Go test does)
    p_ntt = p_ntt.reduceLe2Q();

    const p_restored = p_ntt.invNTT();

    // Result should be 1 * R = toMont(1) in Montgomery form
    const p_reduced = p_restored.reduceLe2Q();
    const p_norm = p_reduced.normalize();

    const expected = modQ(toMont(1));
    try testing.expectEqual(expected, p_norm.cs[0]);

    // All other coefficients should be 0 * R = 0
    for (1..N) |i| {
        try testing.expectEqual(@as(u32, 0), p_norm.cs[i]);
    }
}

test "Montgomery reduction correctness" {
    // Test that Montgomery reduction works correctly
    // montReduceLe2Q(a * b * R) = a * b mod q (where a, b are in Montgomery form)

    const x: u32 = 12345;
    const y: u32 = 67890;

    // Convert to Montgomery form
    const x_mont = toMont(x);
    const y_mont = toMont(y);

    // Multiply in Montgomery form
    const product_mont = montReduceLe2Q(@as(u64, x_mont) * @as(u64, y_mont));

    // Convert back from Montgomery form
    const product = montReduceLe2Q(@as(u64, product_mont));

    // Direct multiplication mod q
    const expected = modQ(@as(u32, @intCast((@as(u64, x) * @as(u64, y)) % Q)));

    try testing.expectEqual(expected, modQ(product));
}

// Removed debug test - was causing noise in output

test "compare inv_zetas with Go implementation" {
    // First 16 inv_zetas from Go implementation
    const go_inv_zetas = [16]u32{
        6403635, 846154,  6979993, 4442679, 1362209, 48306,   4460757,
        554416,  3545687, 6767575, 976891,  8196974, 2286327, 420899,
        2235985, 2939036,
    };

    // Compare our computed inv_zetas with Go's
    for (0..16) |i| {
        if (inv_zetas[i] != go_inv_zetas[i]) {
            std.debug.print("Mismatch at inv_zetas[{d}]: got {d}, expected {d}\n", .{ i, inv_zetas[i], go_inv_zetas[i] });
        }
        try testing.expectEqual(go_inv_zetas[i], inv_zetas[i]);
    }
}

test "power2Round correctness" {
    // Test that power2Round correctly splits values
    // For all a in [0, Q), we should have a = a1*2^D + a0
    // where -2^(D-1) < a0 <= 2^(D-1)

    // Test a few specific values
    const test_values = [_]u32{ 0, 1, Q / 2, Q - 1, 12345, 8380416 };

    for (test_values) |a| {
        if (a >= Q) continue;

        const result = power2Round(a);
        const a0 = @as(i32, @bitCast(result.a0_plus_q -% Q));
        const a1 = result.a1;

        // Check reconstruction: a = a1*2^D + a0
        const reconstructed = @as(i32, @bitCast(a1 << D)) + a0;
        try testing.expectEqual(@as(i32, @bitCast(a)), reconstructed);

        // Check a0 bounds: -2^(D-1) < a0 <= 2^(D-1)
        const bound: i32 = 1 << (D - 1);
        try testing.expect(a0 > -bound and a0 <= bound);
    }
}

test "decompose correctness for ML-DSA-65" {
    // Test decompose with gamma2 = 95232 (ML-DSA-44)
    const gamma2 = 95232;
    const alpha = 2 * gamma2;

    const test_values = [_]u32{ 0, 1, Q / 2, Q - 1, 12345 };

    for (test_values) |a| {
        if (a >= Q) continue;

        const result = decompose(a, gamma2);
        const a0 = @as(i32, @bitCast(result.a0_plus_q -% Q));
        const a1 = result.a1;

        // Check reconstruction: a = a1*alpha + a0 (mod Q)
        var reconstructed: i64 = @as(i64, @intCast(a1)) * @as(i64, @intCast(alpha)) + @as(i64, a0);
        reconstructed = @mod(reconstructed, @as(i64, Q));
        try testing.expectEqual(@as(i64, @intCast(a)), reconstructed);

        // Check a0 bounds (approximately)
        const bound: i32 = @intCast(alpha / 2);
        try testing.expect(@abs(a0) <= bound);
    }
}

test "decompose correctness for ML-DSA-87" {
    // Test decompose with gamma2 = 261888 (ML-DSA-65 and ML-DSA-87)
    const gamma2 = 261888;
    const alpha = 2 * gamma2;

    const test_values = [_]u32{ 0, 1, Q / 2, Q - 1, 12345 };

    for (test_values) |a| {
        if (a >= Q) continue;

        const result = decompose(a, gamma2);
        const a0 = @as(i32, @bitCast(result.a0_plus_q -% Q));
        const a1 = result.a1;

        // Check reconstruction: a = a1*alpha + a0 (mod Q)
        var reconstructed: i64 = @as(i64, @intCast(a1)) * @as(i64, @intCast(alpha)) + @as(i64, a0);
        reconstructed = @mod(reconstructed, @as(i64, Q));
        try testing.expectEqual(@as(i64, @intCast(a)), reconstructed);

        // Check a0 bounds (approximately)
        const bound: i32 = @intCast(alpha / 2);
        try testing.expect(@abs(a0) <= bound);
    }
}

test "polyDeriveUniform deterministic" {
    // Test that polyDeriveUniform produces deterministic results
    const seed: [32]u8 = .{0x01} ++ @as([31]u8, @splat(0x00));
    const nonce: u16 = 0;

    const p1 = polyDeriveUniform(&seed, nonce);
    const p2 = polyDeriveUniform(&seed, nonce);

    // Should be identical
    for (0..N) |i| {
        try testing.expectEqual(p1.cs[i], p2.cs[i]);
    }

    // All coefficients should be in [0, Q)
    for (0..N) |i| {
        try testing.expect(p1.cs[i] < Q);
    }
}

test "polyDeriveUniform different nonces" {
    // Test that different nonces produce different polynomials
    const seed: [32]u8 = .{0x01} ++ @as([31]u8, @splat(0x00));

    const p1 = polyDeriveUniform(&seed, 0);
    const p2 = polyDeriveUniform(&seed, 1);

    // Should be different
    var different = false;
    for (0..N) |i| {
        if (p1.cs[i] != p2.cs[i]) {
            different = true;
            break;
        }
    }
    try testing.expect(different);
}

test "expandS with eta=2" {
    // Test eta=2 sampling
    const seed: [64]u8 = .{0x02} ++ @as([63]u8, @splat(0x00));
    const nonce: u16 = 0;

    const p = expandS(2, &seed, nonce);

    // All coefficients should be in [Q-eta, Q+eta]
    // The function returns coefficients as Q + eta - t, where t is in [0, 2*eta]
    // So coefficients are in [Q-eta, Q+eta]
    for (0..N) |i| {
        const c = p.cs[i];
        // Check that c is in [Q-2, Q+2]
        try testing.expect(c >= Q - 2 and c <= Q + 2);
    }
}

test "expandS with eta=4" {
    // Test eta=4 sampling
    const seed: [64]u8 = .{0x03} ++ @as([63]u8, @splat(0x00));
    const nonce: u16 = 0;

    const p = expandS(4, &seed, nonce);

    // All coefficients should be in [Q-eta, Q+eta]
    for (0..N) |i| {
        const c = p.cs[i];
        // Check bounds (coefficients are around Q ± eta)
        const diff = if (c >= Q) c - Q else Q - c;
        try testing.expect(diff <= 4);
    }
}

test "sampleInBall has correct weight" {
    // Test that ball polynomial has exactly tau non-zero coefficients
    const tau = 39; // From ML-DSA-44
    const seed: [32]u8 = .{0x03} ++ @as([31]u8, @splat(0x00));

    const p = sampleInBall(tau, &seed);

    // Count non-zero coefficients
    var count: u32 = 0;
    for (0..N) |i| {
        if (p.cs[i] != 0) {
            count += 1;
            // Non-zero coefficients should be 1 or Q-1
            try testing.expect(p.cs[i] == 1 or p.cs[i] == Q - 1);
        }
    }

    try testing.expectEqual(tau, count);
}

test "sampleInBall deterministic" {
    // Test that ball sampling is deterministic
    const tau = 49; // From ML-DSA-65
    const seed: [32]u8 = .{0x05} ++ @as([31]u8, @splat(0x00));

    const p1 = sampleInBall(tau, &seed);
    const p2 = sampleInBall(tau, &seed);

    // Should be identical
    for (0..N) |i| {
        try testing.expectEqual(p1.cs[i], p2.cs[i]);
    }
}

test "polyPackLeqEta / polyUnpackLeqEta roundtrip for eta=2" {
    // Test packing and unpacking for eta=2
    const eta = 2;

    // Create a test polynomial with coefficients in [Q-eta, Q+eta]
    var p = Poly.zero;
    for (0..N) |i| {
        // Use various values in range
        const val = @as(u32, @intCast(i % 5)); // 0, 1, 2, 3, 4
        p.cs[i] = Q + eta - val;
    }

    // Pack it
    var buf: [96]u8 = undefined; // eta=2: 3 bits per coeff = 96 bytes
    polyPackLeqEta(p, eta, &buf);

    // Unpack it
    const p2 = polyUnpackLeqEta(eta, &buf);

    // Should be identical
    for (0..N) |i| {
        try testing.expectEqual(p.cs[i], p2.cs[i]);
    }
}

test "polyPackLeqEta / polyUnpackLeqEta roundtrip for eta=4" {
    // Test packing and unpacking for eta=4
    const eta = 4;

    // Create a test polynomial with coefficients in [Q-eta, Q+eta]
    var p = Poly.zero;
    for (0..N) |i| {
        // Use various values in range
        const val = @as(u32, @intCast(i % 9)); // 0, 1, 2, ..., 8
        p.cs[i] = Q + eta - val;
    }

    // Pack it
    var buf: [128]u8 = undefined; // eta=4: 4 bits per coeff = 128 bytes
    polyPackLeqEta(p, eta, &buf);

    // Unpack it
    const p2 = polyUnpackLeqEta(eta, &buf);

    // Should be identical
    for (0..N) |i| {
        try testing.expectEqual(p.cs[i], p2.cs[i]);
    }
}

test "polyPackT1 / polyUnpackT1 roundtrip" {
    // Create a test polynomial with coefficients < 1024
    var p = Poly.zero;
    for (0..N) |i| {
        p.cs[i] = @intCast(i % 1024);
    }

    // Pack it
    var buf: [320]u8 = undefined; // (256 * 10) / 8 = 320 bytes
    polyPackT1(p, &buf);

    // Unpack it
    const p2 = polyUnpackT1(&buf);

    // Should be identical
    for (0..N) |i| {
        try testing.expectEqual(p.cs[i], p2.cs[i]);
    }
}

test "polyPackT0 / polyUnpackT0 roundtrip" {
    // Create a test polynomial with coefficients in (Q-2^12, Q+2^12]
    // This is the range (-2^12, 2^12] represented as unsigned around Q
    const bound = 1 << 12; // 2^(D-1) where D=13
    var p = Poly.zero;
    for (0..N) |i| {
        // Cycle through valid range for T0
        // Values should be Q + offset where offset is in (-bound, bound]
        const cycle_val = @as(i32, @intCast(i % (2 * bound))); // 0 to 2*bound-1
        const offset = cycle_val - bound + 1; // (-bound+1) to bound
        p.cs[i] = @as(u32, @intCast(@as(i32, Q) + offset));
    }

    // Pack it
    var buf: [416]u8 = undefined; // (256 * 13) / 8 = 416 bytes
    polyPackT0(p, &buf);

    // Unpack it
    const p2 = polyUnpackT0(&buf);

    // Should be identical
    for (0..N) |i| {
        try testing.expectEqual(p.cs[i], p2.cs[i]);
    }
}

test "polyPackLeGamma1 / polyUnpackLeGamma1 roundtrip gamma1_bits=17" {
    const gamma1_bits = 17;
    const gamma1: u32 = @as(u32, 1) << gamma1_bits;

    // Create a test polynomial with coefficients in (-gamma1, gamma1]
    // Normalized: [0, gamma1] ∪ (Q-gamma1, Q)
    var p = Poly.zero;
    for (0..N) |i| {
        if (i % 2 == 0) {
            // Positive values: [0, gamma1]
            p.cs[i] = @intCast((i / 2) % (gamma1 + 1));
        } else {
            // Negative values: (Q-gamma1, Q)
            const neg_val: u32 = @intCast(((i / 2) % gamma1) + 1);
            p.cs[i] = Q - neg_val;
        }
    }

    // Pack it
    var buf: [576]u8 = undefined; // (256 * 18) / 8 = 576 bytes
    polyPackLeGamma1(p, gamma1_bits, &buf);

    // Unpack it
    const p2 = polyUnpackLeGamma1(gamma1_bits, &buf);

    // Should be identical
    for (0..N) |i| {
        try testing.expectEqual(p.cs[i], p2.cs[i]);
    }
}

test "polyPackLeGamma1 / polyUnpackLeGamma1 roundtrip gamma1_bits=19" {
    const gamma1_bits = 19;
    const gamma1: u32 = @as(u32, 1) << gamma1_bits;

    // Create a test polynomial with coefficients in (-gamma1, gamma1]
    var p = Poly.zero;
    for (0..N) |i| {
        if (i % 2 == 0) {
            // Positive values: [0, gamma1]
            p.cs[i] = @intCast((i / 2) % (gamma1 + 1));
        } else {
            // Negative values: (Q-gamma1, Q)
            const neg_val: u32 = @intCast(((i / 2) % gamma1) + 1);
            p.cs[i] = Q - neg_val;
        }
    }

    // Pack it
    var buf: [640]u8 = undefined; // (256 * 20) / 8 = 640 bytes
    polyPackLeGamma1(p, gamma1_bits, &buf);

    // Unpack it
    const p2 = polyUnpackLeGamma1(gamma1_bits, &buf);

    // Should be identical
    for (0..N) |i| {
        try testing.expectEqual(p.cs[i], p2.cs[i]);
    }
}

test "polyPackW1 for gamma1_bits=17" {
    const gamma1_bits = 17;

    // Create a test polynomial with small coefficients (w1 values < 64)
    var p = Poly.zero;
    for (0..N) |i| {
        p.cs[i] = @intCast(i % 64); // 6-bit values
    }

    // Pack it
    var buf: [192]u8 = undefined; // (256 * 6) / 8 = 192 bytes
    polyPackW1(p, gamma1_bits, &buf);

    // Verify basic properties
    // All bytes should be used
    var non_zero = false;
    for (buf) |b| {
        if (b != 0) {
            non_zero = true;
            break;
        }
    }
    try testing.expect(non_zero);
}

test "polyPackW1 for gamma1_bits=19" {
    const gamma1_bits = 19;

    // Create a test polynomial with small coefficients (w1 values < 16)
    var p = Poly.zero;
    for (0..N) |i| {
        p.cs[i] = @intCast(i % 16); // 4-bit values
    }

    // Pack it
    var buf: [128]u8 = undefined; // (256 * 4) / 8 = 128 bytes
    polyPackW1(p, gamma1_bits, &buf);

    // Verify basic properties
    var non_zero = false;
    for (buf) |b| {
        if (b != 0) {
            non_zero = true;
            break;
        }
    }
    try testing.expect(non_zero);
}

test "makeHint and useHint correctness for gamma2=261888" {
    // Test for ML-DSA-65 and ML-DSA-87
    const gamma2: u32 = 261888;

    // Test a selection of values to verify the hint mechanism works
    const test_values = [_]u32{ 0, 100, 1000, 10000, 100000, 1000000, Q / 2, Q - 1 };

    for (test_values) |w| {
        // Decompose w to get w0 and w1
        const decomp = decompose(w, gamma2);
        const w0_plus_q = decomp.a0_plus_q;
        const w1 = decomp.a1;

        // Test with various small perturbations f in [0, gamma2]
        const perturbations = [_]u32{ 0, 1, 10, 100, 1000, gamma2 / 2, gamma2 };

        for (perturbations) |f| {
            // Test f (positive perturbation)
            const z0_pos = (w0_plus_q +% Q -% f) % Q;
            const hint_pos = makeHint(z0_pos, w1, gamma2);
            const w_perturbed_pos = (w +% Q -% f) % Q;
            const w1_recovered_pos = useHint(w_perturbed_pos, hint_pos, gamma2);
            try testing.expectEqual(w1, w1_recovered_pos);

            // Test -f (negative perturbation)
            if (f > 0) {
                const z0_neg = (w0_plus_q +% f) % Q;
                const hint_neg = makeHint(z0_neg, w1, gamma2);
                const w_perturbed_neg = (w +% f) % Q;
                const w1_recovered_neg = useHint(w_perturbed_neg, hint_neg, gamma2);
                try testing.expectEqual(w1, w1_recovered_neg);
            }
        }
    }
}

test "makeHint and useHint correctness for gamma2=95232" {
    // Test for ML-DSA-44
    const gamma2: u32 = 95232;

    // Test a selection of values to verify the hint mechanism works
    const test_values = [_]u32{ 0, 100, 1000, 10000, 100000, 1000000, Q / 2, Q - 1 };

    for (test_values) |w| {
        // Decompose w to get w0 and w1
        const decomp = decompose(w, gamma2);
        const w0_plus_q = decomp.a0_plus_q;
        const w1 = decomp.a1;

        // Test with various small perturbations f in [0, gamma2]
        const perturbations = [_]u32{ 0, 1, 10, 100, 1000, gamma2 / 2, gamma2 };

        for (perturbations) |f| {
            // Test f (positive perturbation)
            const z0_pos = (w0_plus_q +% Q -% f) % Q;
            const hint_pos = makeHint(z0_pos, w1, gamma2);
            const w_perturbed_pos = (w +% Q -% f) % Q;
            const w1_recovered_pos = useHint(w_perturbed_pos, hint_pos, gamma2);
            try testing.expectEqual(w1, w1_recovered_pos);

            // Test -f (negative perturbation)
            if (f > 0) {
                const z0_neg = (w0_plus_q +% f) % Q;
                const hint_neg = makeHint(z0_neg, w1, gamma2);
                const w_perturbed_neg = (w +% f) % Q;
                const w1_recovered_neg = useHint(w_perturbed_neg, hint_neg, gamma2);
                try testing.expectEqual(w1, w1_recovered_neg);
            }
        }
    }
}

test "polyMakeHint basic functionality" {
    const gamma2: u32 = 261888;

    // Create test polynomials
    var p0 = Poly.zero;
    var p1 = Poly.zero;

    // Fill with test values
    for (0..N) |i| {
        p0.cs[i] = @intCast((i * 17) % Q);
        p1.cs[i] = @intCast((i * 3) % 16); // High bits are at most 15 for gamma2=261888
    }

    // Make hints
    const result = polyMakeHint(p0, p1, gamma2);
    const hint = result.hint;
    const count = result.count;

    // Verify that hints are binary
    for (0..N) |i| {
        try testing.expect(hint.cs[i] == 0 or hint.cs[i] == 1);
    }

    // Verify that count matches the number of 1s in hint
    var actual_count: u32 = 0;
    for (0..N) |i| {
        actual_count += hint.cs[i];
    }
    try testing.expectEqual(count, actual_count);
}

test "polyUseHint reconstruction" {
    const gamma2: u32 = 261888;

    // Create a test polynomial q
    var q = Poly.zero;
    for (0..N) |i| {
        q.cs[i] = @intCast((i * 123) % Q);
    }

    // Decompose q to get high and low bits
    var q0_plus_q_array: [N]u32 = undefined;
    var q1_array: [N]u32 = undefined;
    for (0..N) |i| {
        const decomp = decompose(q.cs[i], gamma2);
        q0_plus_q_array[i] = decomp.a0_plus_q;
        q1_array[i] = decomp.a1;
    }

    const q0_plus_q = Poly{ .cs = q0_plus_q_array };
    const q1 = Poly{ .cs = q1_array };

    // Create hints (in this case, they'll mostly be 0 since q and q are the same)
    const hint_result = polyMakeHint(q0_plus_q, q1, gamma2);
    const hint = hint_result.hint;

    // Use hints to recover high bits
    const recovered = polyUseHint(q, hint, gamma2);

    // Recovered should match original high bits q1
    for (0..N) |i| {
        try testing.expectEqual(q1.cs[i], recovered.cs[i]);
    }
}

test "hint roundtrip with perturbation" {
    const gamma2: u32 = 261888;

    // Create a test polynomial w
    var w = Poly.zero;
    for (0..N) |i| {
        w.cs[i] = @intCast((i * 7919) % Q);
    }

    // Decompose w to get w0 and w1
    var w0_plus_q = Poly.zero;
    var w1 = Poly.zero;
    for (0..N) |i| {
        const decomp = decompose(w.cs[i], gamma2);
        w0_plus_q.cs[i] = decomp.a0_plus_q;
        w1.cs[i] = decomp.a1;
    }

    // Apply a small perturbation
    var f = Poly.zero;
    for (0..N) |i| {
        // Small perturbation in [-gamma2, gamma2]
        const f_val = @as(u32, @intCast(i % 1000));
        f.cs[i] = if (i % 2 == 0) f_val else Q -% f_val;
    }

    // Compute w' = w - f and z0 = w0 - f
    var w_prime = Poly.zero;
    var z0 = Poly.zero;
    for (0..N) |i| {
        w_prime.cs[i] = (w.cs[i] +% Q -% f.cs[i]) % Q;
        z0.cs[i] = (w0_plus_q.cs[i] +% Q -% f.cs[i]) % Q;
    }

    // Make hints
    const hint_result = polyMakeHint(z0, w1, gamma2);
    const hint = hint_result.hint;

    // Use hints to recover w1 from w_prime
    const w1_recovered = polyUseHint(w_prime, hint, gamma2);

    // Verify that we recovered the original high bits
    for (0..N) |i| {
        try testing.expectEqual(w1.cs[i], w1_recovered.cs[i]);
    }
}

// Parameterized test helper for key generation

fn testKeyGenerationBasic(comptime MlDsa: type, seed: [32]u8) !void {
    const result = MlDsa.newKeyFromSeed(&seed);
    const pk = result.pk;
    const sk = result.sk;

    // Basic sanity checks
    try testing.expect(pk.rho.len == 32);
    try testing.expect(sk.rho.len == 32);
    try testing.expectEqualSlices(u8, &pk.rho, &sk.rho);

    // Verify tr matches between pk and sk
    try testing.expectEqualSlices(u8, &pk.tr, &sk.tr);

    // Test toBytes/fromBytes round-trip for public key
    const pk_bytes = pk.toBytes();
    const pk2 = try MlDsa.PublicKey.fromBytes(pk_bytes);
    try testing.expectEqualSlices(u8, &pk.rho, &pk2.rho);
    try testing.expectEqualSlices(u8, &pk.tr, &pk2.tr);

    // Test toBytes/fromBytes round-trip for secret key
    const sk_bytes = sk.toBytes();
    const sk2 = try MlDsa.SecretKey.fromBytes(sk_bytes);
    try testing.expectEqualSlices(u8, &sk.rho, &sk2.rho);
    try testing.expectEqualSlices(u8, &sk.key, &sk2.key);
    try testing.expectEqualSlices(u8, &sk.tr, &sk2.tr);
}

test "Key generation basic - all variants" {
    inline for (.{
        .{ .variant = MLDSA44, .seed_byte = 0x44 },
        .{ .variant = MLDSA65, .seed_byte = 0x65 },
        .{ .variant = MLDSA87, .seed_byte = 0x87 },
    }) |config| {
        const seed: [32]u8 = @splat(config.seed_byte);
        try testKeyGenerationBasic(config.variant, seed);
    }
}

test "Key generation determinism" {
    const seed = [_]u8{ 0x12, 0x34, 0x56, 0x78 } ++ @as([28]u8, @splat(0xAB));

    // Generate two key pairs from the same seed
    const result1 = MLDSA44.newKeyFromSeed(&seed);
    const result2 = MLDSA44.newKeyFromSeed(&seed);

    // They should be identical
    const pk_bytes1 = result1.pk.toBytes();
    const pk_bytes2 = result2.pk.toBytes();
    try testing.expectEqualSlices(u8, &pk_bytes1, &pk_bytes2);

    const sk_bytes1 = result1.sk.toBytes();
    const sk_bytes2 = result2.sk.toBytes();
    try testing.expectEqualSlices(u8, &sk_bytes1, &sk_bytes2);
}

test "Private key can compute public key" {
    const seed: [32]u8 = @splat(0xFF);
    const result = MLDSA44.newKeyFromSeed(&seed);
    const pk = result.pk;
    const sk = result.sk;

    // Compute public key from private key
    const pk_from_sk = sk.public();

    // Pack both public keys and compare
    const pk_bytes1 = pk.toBytes();
    const pk_bytes2 = pk_from_sk.toBytes();

    try testing.expectEqualSlices(u8, &pk_bytes1, &pk_bytes2);
}

// Parameterized test helper for sign and verify
fn testSignAndVerify(comptime MlDsa: type, seed: [32]u8, message: []const u8) !void {
    const result = MlDsa.newKeyFromSeed(&seed);
    const kp = try MlDsa.KeyPair.fromSecretKey(result.sk);

    // Sign the message
    const sig = try kp.sign(message, null);

    // Verify the signature
    try sig.verify(message, kp.public_key);
}

test "Sign and verify - all variants" {
    inline for (.{
        .{ .variant = MLDSA44, .seed_byte = 0x44, .message = "Hello, ML-DSA-44!" },
        .{ .variant = MLDSA65, .seed_byte = 0x65, .message = "Hello, ML-DSA-65!" },
        .{ .variant = MLDSA87, .seed_byte = 0x87, .message = "Hello, ML-DSA-87!" },
    }) |config| {
        const seed: [32]u8 = @splat(config.seed_byte);
        try testSignAndVerify(config.variant, seed, config.message);
    }
}

test "Invalid signature rejection" {
    const seed: [32]u8 = @splat(0x99);
    const result = MLDSA44.newKeyFromSeed(&seed);
    const kp = try MLDSA44.KeyPair.fromSecretKey(result.sk);

    const message = "Original message";

    // Sign the message
    const sig = try kp.sign(message, null);

    // Verify with wrong message should fail
    const wrong_message = "Modified message";
    try testing.expectError(error.SignatureVerificationFailed, sig.verify(wrong_message, kp.public_key));

    // Modify signature and verify should fail
    var corrupted_sig_bytes = sig.toBytes();
    corrupted_sig_bytes[0] ^= 0xFF;
    const corrupted_sig = try MLDSA44.Signature.fromBytes(corrupted_sig_bytes);
    try testing.expectError(error.SignatureVerificationFailed, corrupted_sig.verify(message, kp.public_key));
}

test "Context string support" {
    const seed: [32]u8 = @splat(0xAA);
    const result = MLDSA44.newKeyFromSeed(&seed);
    const kp = try MLDSA44.KeyPair.fromSecretKey(result.sk);

    const message = "Test message";
    const context1 = "context1";
    const context2 = "context2";

    // Sign with context1
    const sig1 = try kp.signWithContext(message, null, context1);

    // Verify with correct context should succeed
    try sig1.verifyWithContext(message, kp.public_key, context1);

    // Verify with wrong context should fail
    try testing.expectError(error.SignatureVerificationFailed, sig1.verifyWithContext(message, kp.public_key, context2));

    // Verify with empty context should fail
    try testing.expectError(error.SignatureVerificationFailed, sig1.verify(message, kp.public_key));

    // Sign with empty context
    const sig2 = try kp.sign(message, null);

    // Verify with empty context should succeed
    try sig2.verify(message, kp.public_key);

    // Verify with non-empty context should fail
    try testing.expectError(error.SignatureVerificationFailed, sig2.verifyWithContext(message, kp.public_key, context1));

    // Test maximum context length (255 bytes)
    const max_context: [255]u8 = @splat(0xBB);
    const sig3 = try kp.signWithContext(message, null, &max_context);
    try sig3.verifyWithContext(message, kp.public_key, &max_context);

    // Test context too long (256 bytes should fail)
    const too_long_context: [256]u8 = @splat(0xCC);
    try testing.expectError(error.ContextTooLong, kp.signWithContext(message, null, &too_long_context));
}

test "Context string with streaming API" {
    const seed: [32]u8 = @splat(0xDD);
    const result = MLDSA44.newKeyFromSeed(&seed);
    const kp = try MLDSA44.KeyPair.fromSecretKey(result.sk);

    const context = "streaming-context";
    const message_part1 = "Hello, ";
    const message_part2 = "World!";

    // Sign using streaming API with context
    var signer = try kp.signerWithContext(null, context);
    signer.update(message_part1);
    signer.update(message_part2);
    const sig = signer.finalize();

    // Verify using streaming API with context
    var verifier = try sig.verifierWithContext(kp.public_key, context);
    verifier.update(message_part1);
    verifier.update(message_part2);
    try verifier.verify();

    // Verify with wrong context should fail
    var verifier_wrong = try sig.verifierWithContext(kp.public_key, "wrong");
    verifier_wrong.update(message_part1);
    verifier_wrong.update(message_part2);
    try testing.expectError(error.SignatureVerificationFailed, verifier_wrong.verify());
}

test "Signature determinism (same rnd)" {
    const seed: [32]u8 = @splat(0x11);
    const result = MLDSA44.newKeyFromSeed(&seed);
    const sk = result.sk;

    const message = "Deterministic test";
    const rnd: [32]u8 = @splat(0x22);

    // Sign twice with same randomness using streaming API
    var st1 = try sk.signer(rnd);
    st1.update(message);
    const sig1 = st1.finalize();

    var st2 = try sk.signer(rnd);
    st2.update(message);
    const sig2 = st2.finalize();

    // Signatures should be identical
    try testing.expectEqualSlices(u8, &sig1.toBytes(), &sig2.toBytes());
}

test "Signature toBytes/fromBytes roundtrip" {
    const seed: [32]u8 = @splat(0x33);
    const result = MLDSA44.newKeyFromSeed(&seed);
    const kp = try MLDSA44.KeyPair.fromSecretKey(result.sk);

    const message = "toBytes/fromBytes test";

    // Sign the message
    const sig = try kp.sign(message, null);
    const sig_bytes = sig.toBytes();

    // Unpack and repack
    const sig_reparsed = try MLDSA44.Signature.fromBytes(sig_bytes);

    const repacked = sig_reparsed.toBytes();

    // Should match original
    try testing.expectEqualSlices(u8, &sig_bytes, &repacked);
}

test "Empty message signing" {
    const seed: [32]u8 = @splat(0x44);
    const result = MLDSA44.newKeyFromSeed(&seed);
    const kp = try MLDSA44.KeyPair.fromSecretKey(result.sk);

    const message = "";

    // Sign empty message
    const sig = try kp.sign(message, null);

    // Verify should work
    try sig.verify(message, kp.public_key);
}

test "Long message signing" {
    const seed: [32]u8 = @splat(0x55);
    const result = MLDSA44.newKeyFromSeed(&seed);
    const kp = try MLDSA44.KeyPair.fromSecretKey(result.sk);

    // Create a long message (1KB)
    const long_message: [1024]u8 = @splat(0xAB);

    // Sign long message
    const sig = try kp.sign(&long_message, null);

    // Verify should work
    try sig.verify(&long_message, kp.public_key);
}

// Helper function to decode hex string into bytes
fn hexToBytes(comptime hex: []const u8, out: []u8) !void {
    if (hex.len != out.len * 2) return error.InvalidLength;

    var i: usize = 0;
    while (i < out.len) : (i += 1) {
        const hi = try std.fmt.charToDigit(hex[i * 2], 16);
        const lo = try std.fmt.charToDigit(hex[i * 2 + 1], 16);
        out[i] = (hi << 4) | lo;
    }
}

test "ML-DSA-44 KAT test vector 0" {
    // Test vector from NIST ML-DSA KAT (count = 0)
    // xi is the seed for key generation (Algorithm 1, line 1)
    const xi_hex = "f696484048ec21f96cf50a56d0759c448f3779752f0383d37449690694cf7a68";
    const pk_hex_start = "bd4e96f9a038ab5e36214fe69c0b1cb835ef9d7c8417e76aecd152f5cddebec8";
    const msg_hex = "6dbbc4375136df3b07f7c70e639e223e";

    // Parse xi (32-byte seed for key generation)
    var xi: [32]u8 = undefined;
    try hexToBytes(xi_hex, &xi);

    // Generate keys from xi
    const result = MLDSA44.newKeyFromSeed(&xi);
    const pk = result.pk;
    const sk = result.sk;

    // Verify public key starts with expected bytes
    const pk_bytes = pk.toBytes();

    var expected_pk_start: [32]u8 = undefined;
    try hexToBytes(pk_hex_start, &expected_pk_start);

    // Check first 32 bytes of public key match
    try testing.expectEqualSlices(u8, &expected_pk_start, pk_bytes[0..32]);

    // Parse message
    var msg: [16]u8 = undefined;
    try hexToBytes(msg_hex, &msg);

    // Sign the message (deterministic mode with fixed randomness)
    const kp = try MLDSA44.KeyPair.fromSecretKey(sk);
    const sig = try kp.sign(&msg, null);

    // Verify the signature
    try sig.verify(&msg, kp.public_key);
}

test "ML-DSA-65 KAT test vector 0" {
    // Test vector from NIST ML-DSA KAT (count = 0)
    // xi is the seed for key generation (Algorithm 1, line 1)
    const xi_hex = "f696484048ec21f96cf50a56d0759c448f3779752f0383d37449690694cf7a68";
    const pk_hex_start = "e50d03fff3b3a70961abbb92a390008dec1283f603f50cdbaaa3d00bd659bc76";
    const msg_hex = "6dbbc4375136df3b07f7c70e639e223e";

    // Parse xi (32-byte seed for key generation)
    var xi: [32]u8 = undefined;
    try hexToBytes(xi_hex, &xi);

    // Generate keys from xi
    const result = MLDSA65.newKeyFromSeed(&xi);
    const pk = result.pk;
    const sk = result.sk;

    // Verify public key starts with expected bytes
    const pk_bytes = pk.toBytes();

    var expected_pk_start: [32]u8 = undefined;
    try hexToBytes(pk_hex_start, &expected_pk_start);

    // Check first 32 bytes of public key match
    try testing.expectEqualSlices(u8, &expected_pk_start, pk_bytes[0..32]);

    // Parse message
    var msg: [16]u8 = undefined;
    try hexToBytes(msg_hex, &msg);

    // Sign the message
    const kp = try MLDSA65.KeyPair.fromSecretKey(sk);
    const sig = try kp.sign(&msg, null);

    // Verify the signature
    try sig.verify(&msg, kp.public_key);
}

test "ML-DSA-87 KAT test vector 0" {
    // Test vector from NIST ML-DSA KAT (count = 0)
    // xi is the seed for key generation (Algorithm 1, line 1)
    const xi_hex = "f696484048ec21f96cf50a56d0759c448f3779752f0383d37449690694cf7a68";
    const pk_hex_start = "bc89b367d4288f47c71a74679d0fcffbe041de41b5da2f5fc66d8e28c5899494";
    const msg_hex = "6dbbc4375136df3b07f7c70e639e223e";

    // Parse xi (32-byte seed for key generation)
    var xi: [32]u8 = undefined;
    try hexToBytes(xi_hex, &xi);

    // Generate keys from xi
    const result = MLDSA87.newKeyFromSeed(&xi);
    const pk = result.pk;
    const sk = result.sk;

    // Verify public key starts with expected bytes
    const pk_bytes = pk.toBytes();

    var expected_pk_start: [32]u8 = undefined;
    try hexToBytes(pk_hex_start, &expected_pk_start);

    // Check first 32 bytes of public key match
    try testing.expectEqualSlices(u8, &expected_pk_start, pk_bytes[0..32]);

    // Parse message
    var msg: [16]u8 = undefined;
    try hexToBytes(msg_hex, &msg);

    // Sign the message
    const kp = try MLDSA87.KeyPair.fromSecretKey(sk);
    const sig = try kp.sign(&msg, null);

    // Verify the signature
    try sig.verify(&msg, kp.public_key);
}

test "KeyPair API - generate and sign" {
    const io = std.testing.io;
    // Test the new KeyPair API with random generation
    const kp = MLDSA44.KeyPair.generate(io);
    const msg = "Test message for KeyPair API";

    // Sign with deterministic mode (no noise)
    const sig = try kp.sign(msg, null);

    // Verify using Signature.verify API
    try sig.verify(msg, kp.public_key);
}

test "KeyPair API - generateDeterministic" {
    // Test deterministic key generation
    const seed: [32]u8 = @splat(42);
    const kp1 = try MLDSA44.KeyPair.generateDeterministic(seed);
    const kp2 = try MLDSA44.KeyPair.generateDeterministic(seed);

    // Same seed should produce same keys
    const pk1_bytes = kp1.public_key.toBytes();
    const pk2_bytes = kp2.public_key.toBytes();
    try testing.expectEqualSlices(u8, &pk1_bytes, &pk2_bytes);
}

test "KeyPair API - fromSecretKey" {
    const io = std.testing.io;
    // Generate a key pair
    const kp1 = MLDSA44.KeyPair.generate(io);

    // Derive public key from secret key
    const kp2 = try MLDSA44.KeyPair.fromSecretKey(kp1.secret_key);

    // Public keys should match
    const pk1_bytes = kp1.public_key.toBytes();
    const pk2_bytes = kp2.public_key.toBytes();
    try testing.expectEqualSlices(u8, &pk1_bytes, &pk2_bytes);
}

test "Signature verification with noise" {
    const io = std.testing.io;
    // Test signing with randomness (hedged signatures)
    const kp = MLDSA65.KeyPair.generate(io);
    const msg = "Message to be signed with randomness";

    // Create some noise
    const noise = [_]u8{ 1, 2, 3, 4, 5 } ++ @as([27]u8, @splat(0));

    // Sign with noise
    const sig = try kp.sign(msg, noise);

    // Verify should still work
    try sig.verify(msg, kp.public_key);
}

test "Signature verification failure" {
    const io = std.testing.io;
    // Test that invalid signatures are rejected
    const kp = MLDSA44.KeyPair.generate(io);
    const msg = "Original message";
    const sig = try kp.sign(msg, null);

    // Verify with wrong message should fail
    const wrong_msg = "Different message";
    try testing.expectError(error.SignatureVerificationFailed, sig.verify(wrong_msg, kp.public_key));
}

test "Streaming API - sign and verify" {
    const seed: [32]u8 = @splat(0x55);
    const kp = try MLDSA44.KeyPair.generateDeterministic(seed);

    const msg = "Test message for streaming API";

    // Sign using streaming API
    var signer = try kp.signer(null);
    signer.update(msg);
    const sig = signer.finalize();

    // Verify using streaming API
    var verifier = try sig.verifier(kp.public_key);
    verifier.update(msg);
    try verifier.verify();
}

test "Streaming API - chunked message" {
    const seed: [32]u8 = @splat(0x66);
    const kp = try MLDSA44.KeyPair.generateDeterministic(seed);

    // Create a message in chunks
    const chunk1 = "Hello, ";
    const chunk2 = "streaming ";
    const chunk3 = "world!";
    const full_msg = chunk1 ++ chunk2 ++ chunk3;

    // Sign with chunks
    var signer = try kp.signer(null);
    signer.update(chunk1);
    signer.update(chunk2);
    signer.update(chunk3);
    const sig_chunked = signer.finalize();

    // Sign with full message for comparison
    var signer2 = try kp.signer(null);
    signer2.update(full_msg);
    const sig_full = signer2.finalize();

    // Signatures should be identical
    try testing.expectEqualSlices(u8, &sig_chunked.toBytes(), &sig_full.toBytes());

    // Verify with chunks
    const sig = sig_chunked;
    var verifier = try sig.verifier(kp.public_key);
    verifier.update(chunk1);
    verifier.update(chunk2);
    verifier.update(chunk3);
    try verifier.verify();
}

test "Streaming API - large message" {
    const seed: [32]u8 = @splat(0x77);
    const kp = try MLDSA44.KeyPair.generateDeterministic(seed);

    // Create a large message (1MB)
    const chunk_size = 4096;
    const num_chunks = 256;
    var chunk: [chunk_size]u8 = undefined;
    for (0..chunk_size) |i| {
        chunk[i] = @intCast(i % 256);
    }

    // Sign streaming
    var signer = try kp.signer(null);
    for (0..num_chunks) |_| {
        signer.update(&chunk);
    }
    const sig = signer.finalize();

    // Verify streaming
    var verifier = try sig.verifier(kp.public_key);
    for (0..num_chunks) |_| {
        verifier.update(&chunk);
    }
    try verifier.verify();
}

test "Streaming API - all parameter sets" {
    const test_msg = "Streaming test for all ML-DSA parameter sets";

    // ML-DSA-44
    {
        const seed: [32]u8 = @splat(0x44);
        const kp = try MLDSA44.KeyPair.generateDeterministic(seed);
        var signer = try kp.signer(null);
        signer.update(test_msg);
        const sig = signer.finalize();
        var verifier = try sig.verifier(kp.public_key);
        verifier.update(test_msg);
        try verifier.verify();
    }

    // ML-DSA-65
    {
        const seed: [32]u8 = @splat(0x65);
        const kp = try MLDSA65.KeyPair.generateDeterministic(seed);
        var signer = try kp.signer(null);
        signer.update(test_msg);
        const sig = signer.finalize();
        var verifier = try sig.verifier(kp.public_key);
        verifier.update(test_msg);
        try verifier.verify();
    }

    // ML-DSA-87
    {
        const seed: [32]u8 = @splat(0x87);
        const kp = try MLDSA87.KeyPair.generateDeterministic(seed);
        var signer = try kp.signer(null);
        signer.update(test_msg);
        const sig = signer.finalize();
        var verifier = try sig.verifier(kp.public_key);
        verifier.update(test_msg);
        try verifier.verify();
    }
}

/// Extended Euclidian Algorithm
/// Only meant to be used on comptime values; correctness matters, performance doesn't.
fn extendedEuclidean(comptime T: type, comptime a_: T, comptime b_: T) struct { gcd: T, x: T, y: T } {
    var a = a_;
    var b = b_;
    var x0: T = 1;
    var x1: T = 0;
    var y0: T = 0;
    var y1: T = 1;

    while (b != 0) {
        const q = @divTrunc(a, b);
        const temp_a = a;
        a = b;
        b = temp_a - q * b;

        const temp_x = x0;
        x0 = x1;
        x1 = temp_x - q * x1;

        const temp_y = y0;
        y0 = y1;
        y1 = temp_y - q * y1;
    }

    return .{ .gcd = a, .x = x0, .y = y0 };
}

/// Modular inversion: computes a^(-1) mod p
/// Requires gcd(a,p) = 1. The result is normalized to the range [0, p).
fn modularInverse(comptime T: type, comptime a: T, comptime p: T) T {
    // Use a signed type for EEA computation
    const type_info = @typeInfo(T);
    const SignedT = if (type_info == .int and type_info.int.signedness == .unsigned)
        @Int(.signed, type_info.int.bits)
    else
        T;

    const a_signed = @as(SignedT, @intCast(a));
    const p_signed = @as(SignedT, @intCast(p));

    const r = extendedEuclidean(SignedT, a_signed, p_signed);
    assert(r.gcd == 1);

    // Normalize result to [0, p)
    var result = r.x;
    while (result < 0) {
        result += p_signed;
    }

    return @intCast(result);
}

/// Modular exponentiation: computes a^s mod p using square-and-multiply algorithm.
fn modularPow(comptime T: type, comptime a: T, s: T, comptime p: T) T {
    const type_info = @typeInfo(T);
    const bits = type_info.int.bits;
    const WideT = @Int(.unsigned, bits * 2);

    var ret: T = 1;
    var base: T = a;
    var exp = s;

    while (exp > 0) {
        if (exp & 1 == 1) {
            ret = @intCast((@as(WideT, ret) * @as(WideT, base)) % p);
        }
        base = @intCast((@as(WideT, base) * @as(WideT, base)) % p);
        exp >>= 1;
    }

    return ret;
}

/// Creates an all-ones or all-zeros mask from a single bit value.
/// Returns all 1s (0xFF...FF) if bit == 1, all 0s if bit == 0.
fn bitMask(comptime T: type, bit: T) T {
    const type_info = @typeInfo(T);
    if (type_info != .int or type_info.int.signedness != .unsigned) {
        @compileError("bitMask requires an unsigned integer type");
    }
    return -%bit;
}

/// Creates a mask from the sign bit of a signed integer.
/// Returns all 1s (0xFF...FF) if x < 0, all 0s if x >= 0.
fn signMask(comptime T: type, x: T) @Int(.unsigned, @typeInfo(T).int.bits) {
    const type_info = @typeInfo(T);
    if (type_info != .int) {
        @compileError("signMask requires an integer type");
    }

    const bits = type_info.int.bits;
    const SignedT = @Int(.signed, bits);

    // Convert to signed if needed, arithmetic right shift to propagate sign bit
    const x_signed: SignedT = if (type_info.int.signedness == .signed) x else @bitCast(x);
    const shifted = x_signed >> (bits - 1);
    return @bitCast(shifted);
}

/// Montgomery reduction: for input x, returns y where y ≡ x*R^(-1) (mod q).
/// This is a generic implementation parameterized by the modulus q, its inverse qInv,
/// the Montgomery constant R, and the result bound.
///
/// For ML-DSA: R = 2^32, returns y < 2q
/// For ML-KEM: R = 2^16, returns y in range (-q, q)
fn montgomeryReduce(
    comptime InT: type,
    comptime OutT: type,
    comptime q: comptime_int,
    comptime qInv: comptime_int,
    comptime r_bits: comptime_int,
    x: InT,
) OutT {
    const mask = (@as(InT, 1) << r_bits) - 1;
    const m_full = (x *% qInv) & mask;
    const m: OutT = @truncate(m_full);

    const yR = x -% @as(InT, m) * @as(InT, q);
    const y_shifted = @as(@Int(.unsigned, @typeInfo(InT).Int.bits), @bitCast(yR)) >> r_bits;
    return @bitCast(@as(@Int(.unsigned, @typeInfo(OutT).Int.bits), @truncate(y_shifted)));
}

/// Uniform sampling using SHAKE-128 with rejection sampling.
/// Samples polynomial coefficients uniformly from [0, q) using rejection sampling.
///
/// Parameters:
/// - PolyType: The polynomial type to return
/// - q: Modulus
/// - bits_per_coef: Number of bits per coefficient (12 or 23)
/// - n: Number of coefficients
/// - seed: Random seed
/// - domain_sep: Domain separation bytes (appended to seed)
fn sampleUniformRejection(
    comptime PolyType: type,
    comptime q: comptime_int,
    comptime bits_per_coef: comptime_int,
    comptime n: comptime_int,
    seed: []const u8,
    domain_sep: []const u8,
) PolyType {
    var h = sha3.Shake128.init(.{});
    h.update(seed);
    h.update(domain_sep);

    const buf_len = sha3.Shake128.block_length; // 168 bytes
    var buf: [buf_len]u8 = undefined;

    var ret: PolyType = undefined;
    var coef_idx: usize = 0;

    if (bits_per_coef == 12) {
        // ML-KEM path: pack 2 coefficients per 3 bytes (12 bits each)
        outer: while (true) {
            h.squeeze(&buf);

            var j: usize = 0;
            while (j < buf_len) : (j += 3) {
                const b0 = @as(u16, buf[j]);
                const b1 = @as(u16, buf[j + 1]);
                const b2 = @as(u16, buf[j + 2]);

                const ts: [2]u16 = .{
                    b0 | ((b1 & 0xf) << 8),
                    (b1 >> 4) | (b2 << 4),
                };

                inline for (ts) |t| {
                    if (t < q) {
                        ret.cs[coef_idx] = @intCast(t);
                        coef_idx += 1;
                        if (coef_idx == n) break :outer;
                    }
                }
            }
        }
    } else if (bits_per_coef == 23) {
        // ML-DSA path: 1 coefficient per 3 bytes (23 bits)
        while (coef_idx < n) {
            h.squeeze(&buf);

            var j: usize = 0;
            while (j < buf_len and coef_idx < n) : (j += 3) {
                const t = (@as(u32, buf[j]) |
                    (@as(u32, buf[j + 1]) << 8) |
                    (@as(u32, buf[j + 2]) << 16)) & 0x7fffff;

                if (t < q) {
                    ret.cs[coef_idx] = @intCast(t);
                    coef_idx += 1;
                }
            }
        }
    } else {
        @compileError("bits_per_coef must be 12 or 23");
    }

    return ret;
}

test "bitMask and signMask helpers" {
    try testing.expectEqual(@as(u32, 0x00000000), bitMask(u32, 0));
    try testing.expectEqual(@as(u32, 0xFFFFFFFF), bitMask(u32, 1));
    try testing.expectEqual(@as(u8, 0x00), bitMask(u8, 0));
    try testing.expectEqual(@as(u8, 0xFF), bitMask(u8, 1));
    try testing.expectEqual(@as(u64, 0x0000000000000000), bitMask(u64, 0));
    try testing.expectEqual(@as(u64, 0xFFFFFFFFFFFFFFFF), bitMask(u64, 1));

    try testing.expectEqual(@as(u32, 0xFFFFFFFF), signMask(i32, -1));
    try testing.expectEqual(@as(u32, 0xFFFFFFFF), signMask(i32, -100));
    try testing.expectEqual(@as(u32, 0x00000000), signMask(i32, 0));
    try testing.expectEqual(@as(u32, 0x00000000), signMask(i32, 1));
    try testing.expectEqual(@as(u32, 0x00000000), signMask(i32, 100));

    try testing.expectEqual(@as(u32, 0xFFFFFFFF), signMask(u32, 0x80000000)); // MSB set
    try testing.expectEqual(@as(u32, 0x00000000), signMask(u32, 0x7FFFFFFF)); // MSB clear
}