//! The ws target: a server's frame parser, as C's `fuzz-ws` has it, after
//! an upgrade.

use npro_ws::conn::{Event, Kind, Ws};

use crate::targets::{Pieces, control, finding};

const TARGET: &str = "ws-server";

/// The most a piece is unmasked into: the fuzzer's input is copied here,
/// since the parser unmasks where the bytes lie.
const MAX_INPUT: usize = 64 * 1024;

/// What the application was handed, a message being whole once it is.
#[derive(Debug, PartialEq, Eq)]
enum Given {
    Message(Kind, Vec<u8>),
    Pong(Vec<u8>),
    PeerClose(Vec<u8>),
}

/// Everything a run came to: what the application was handed, the part of
/// a message still open, what the server wrote, and how it closed.
#[derive(Debug, PartialEq, Eq)]
struct Run {
    given: Vec<Given>,
    open: Option<(Kind, Vec<u8>)>,
    wrote: Vec<u8>,
    close: Option<npro_ws::conn::Close>,
}

struct Nothing;

impl npro_h1::server::TxSource for Nothing {
    fn fill(&mut self, _: &mut [u8]) -> usize {
        0
    }
}

/// Feeds `pieces` to a fresh server, checking what each call says as it
/// goes; writes nothing until the end, so the run does not depend on when
/// the pong slot is drained.
fn run<'a>(how: &str, pieces: impl Iterator<Item = &'a [u8]>) -> Run {
    let mut ws = Ws::server(b"");
    let mut given = Vec::new();
    let mut open: Option<(Kind, Vec<u8>)> = None;
    let mut buf = Vec::new();
    for piece in pieces {
        buf.clear();
        buf.extend_from_slice(piece);
        let mut at = 0usize;
        while let Some(input) = buf.get_mut(at..).filter(|i| !i.is_empty()) {
            let rx = ws.rx(input);
            if rx.consumed == 0 {
                finding(
                    TARGET,
                    format_args!("{how}: took none of {} bytes", input.len()),
                );
            }
            at = at.saturating_add(rx.consumed);
            let Some(ev) = rx.event else { continue };
            if matches!(given.last(), Some(Given::PeerClose(_))) {
                finding(TARGET, format_args!("{how}: {ev:?} after the peer's close"));
            }
            match ev {
                Event::Message {
                    kind,
                    data,
                    first,
                    last,
                } => {
                    let mut m = match (first, open.take()) {
                        (true, None) => (kind, Vec::new()),
                        (false, Some(m)) if m.0 == kind => m,
                        (_, was) => finding(
                            TARGET,
                            format_args!("{how}: first {first} with {was:?} open"),
                        ),
                    };
                    m.1.extend_from_slice(data);
                    if last {
                        given.push(Given::Message(m.0, m.1));
                    } else {
                        open = Some(m);
                    }
                }
                Event::Pong(p) => given.push(Given::Pong(p.to_vec())),
                Event::PeerClose(p) => given.push(Given::PeerClose(p.to_vec())),
            }
        }
    }
    let mut wrote = Vec::new();
    let mut out = [0u8; 64];
    loop {
        let n = ws.tx(&mut out, &mut Nothing);
        if n == 0 {
            break;
        }
        wrote.extend_from_slice(out.get(..n).unwrap_or_default());
    }
    Run {
        given,
        open,
        wrote,
        close: ws.close(),
    }
}

/// The frames a server wrote, checked as a client would read them: each
/// whole, final and unmasked, a control frame at most 125 bytes, a close's
/// payload a code and its reason, and nothing after a close.
///
/// `peer_close` is the payload of the peer's close, if it sent one.  C
/// answers with it whatever it is, a lone byte included, which RFC 6455
/// 5.5.1 does not allow, and npro does as C does: so a close of one byte
/// is taken from npro only as that echo.
fn check_written(wrote: &[u8], peer_close: Option<&[u8]>) {
    let mut rest = wrote;
    while let [b0, b1, tail @ ..] = rest {
        let (op, len) = (b0 & 0x0f, usize::from(b1 & 0x7f));
        if b0 & 0xf0 != 0x80 || b1 & 0x80 != 0 || len > 125 {
            finding(TARGET, format_args!("wrote a frame {b0:#04x} {b1:#04x}"));
        }
        let Some((payload, after)) = tail.split_at_checked(len) else {
            finding(
                TARGET,
                format_args!("wrote {} of a {len} byte frame", tail.len()),
            );
        };
        match op {
            // a pong, of a ping's payload
            0xa => {}
            0x8 => {
                let echoed = peer_close == Some(payload);
                if (payload.len() == 1 && !echoed) || !after.is_empty() {
                    finding(
                        TARGET,
                        format_args!("wrote a close {payload:?} then {after:?}"),
                    );
                }
            }
            _ => finding(
                TARGET,
                format_args!("wrote opcode {op:#x} with nothing sent"),
            ),
        }
        rest = after;
    }
    if !rest.is_empty() {
        finding(
            TARGET,
            format_args!("wrote a frame of {} bytes", rest.len()),
        );
    }
}

/// The text message a run leaves open, empty if none; `None` for binary.
fn open_text(r: &Run) -> Option<&[u8]> {
    match &r.open {
        None => Some(&[]),
        Some((Kind::Text, t)) => Some(t),
        Some((Kind::Binary, _)) => None,
    }
}

/// Whether two runs leave the same message open.  Text is handed over a
/// piece at a time once each piece checks out, so where it turns out not to
/// be UTF-8, how much of its good start went first depends on the split:
/// then, and only then, one run's open message need only start the other's.
fn opens_agree(a: &Run, b: &Run) -> bool {
    if a.open == b.open {
        return true;
    }
    let bad_utf8 = a.wrote.get(..4) == Some(b"\x88\x0a\x03\xef".as_slice())
        || a.wrote.get(..4) == Some(b"\x88\x0e\x03\xef".as_slice());
    match (open_text(a), open_text(b)) {
        (Some(x), Some(y)) => bad_utf8 && (x.starts_with(y) || y.starts_with(x)),
        (None, _) | (_, None) => false,
    }
}

/// A client's frames as a server reads them after the upgrade: C's
/// `fuzz-ws`.
///
/// The first byte chooses how the rest is split.  In one piece and in
/// pieces, the server must hand the application the same messages, pongs
/// and close, write the same, and close the same, of a message it refuses
/// as not UTF-8 having handed over a start of it that may differ (see
/// `opens_agree`); every call must take
/// something; a message's pieces must say where it starts; nothing may
/// follow the peer's close; a whole text message must be UTF-8 by
/// `core::str`; and what the server writes must be frames a client reads.
pub fn ws_server(data: &[u8]) {
    let (ctl, frames) = control(data);
    let frames = frames.get(..MAX_INPUT).unwrap_or(frames);
    let whole = run("whole", core::iter::once(frames));
    let pieces = run("in pieces", Pieces::new(ctl, frames));
    if whole.given != pieces.given
        || whole.wrote != pieces.wrote
        || whole.close != pieces.close
        || !opens_agree(&whole, &pieces)
    {
        finding(
            TARGET,
            format_args!("whole {whole:?}, in pieces {pieces:?}"),
        );
    }
    let texts = whole.given.iter().filter_map(|g| match g {
        Given::Message(Kind::Text, t) => Some(t),
        Given::Message(Kind::Binary, _) | Given::Pong(_) | Given::PeerClose(_) => None,
    });
    for t in texts {
        if core::str::from_utf8(t).is_err() {
            finding(TARGET, format_args!("text {t:?} is not UTF-8"));
        }
    }
    let peer_close = whole.given.iter().find_map(|g| match g {
        Given::PeerClose(p) => Some(p.as_slice()),
        Given::Message(..) | Given::Pong(_) => None,
    });
    check_written(&whole.wrote, peer_close);
}