//! A small HTTP/1.1 client over plain TCP, enough to drive an implementation behind a URL:
//! one request a connection (`Connection: close`), a body by `Content-Length` or chunked,
//! and a response read whole up to a bound.

use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};

/// The most a response may hold.
const MAX_RESPONSE: usize = 512 << 20;

/// An `http://` URL: where to connect, the `Host` header, and the path it starts with.
#[derive(Clone, Debug)]
pub struct Url {
    pub authority: String,
    pub prefix: String,
}

impl Url {
    pub fn parse(u: &str) -> Result<Url, String> {
        let rest = u.strip_prefix("http://").ok_or_else(|| {
            if u.starts_with("https://") {
                format!("{u}: this build speaks plain HTTP to an implementation; serve it on an http:// address (a loopback port, or a tunnel to one)")
            } else {
                format!("{u}: not an http:// URL")
            }
        })?;
        let (authority, path) = match rest.find('/') {
            Some(i) => (&rest[..i], &rest[i..]),
            None => (rest, "/"),
        };
        if authority.is_empty() {
            return Err(format!("{u}: no host"));
        }
        Ok(Url { authority: authority.to_string(), prefix: path.trim_end_matches('/').to_string() })
    }

    /// The path `p` (which starts with `/`, or is empty for the URL itself) below the URL.
    pub fn join(&self, p: &str) -> String {
        let s = format!("{}{p}", self.prefix);
        if s.is_empty() { "/".into() } else { s }
    }
}

#[derive(Debug)]
pub struct Response {
    pub status: u16,
    pub headers: Vec<(String, String)>,
    pub body: Vec<u8>,
}

impl Response {
    /// Every value of the header `name` (case-insensitive), joined with `, `.
    pub fn header(&self, name: &str) -> Option<String> {
        let v: Vec<&str> = self.headers.iter().filter(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()).collect();
        if v.is_empty() { None } else { Some(v.join(", ")) }
    }
}

/// Why a request got no response.
#[derive(Debug)]
pub enum Failure {
    /// No connection: the implementation is not there (or not yet).
    Unreachable(String),
    /// A connection, and no readable response.
    Broken(String),
}

impl std::fmt::Display for Failure {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Failure::Unreachable(e) => write!(f, "unreachable: {e}"),
            Failure::Broken(e) => write!(f, "{e}"),
        }
    }
}

fn dechunk(mut b: &[u8]) -> Option<Vec<u8>> {
    let mut out = Vec::new();
    loop {
        let nl = b.windows(2).position(|w| w == b"\r\n")?;
        let size_text = std::str::from_utf8(&b[..nl]).ok()?;
        let size = usize::from_str_radix(size_text.split(';').next()?.trim(), 16).ok()?;
        b = &b[nl + 2..];
        if size == 0 {
            return Some(out);
        }
        if b.len() < size + 2 {
            return None;
        }
        out.extend_from_slice(&b[..size]);
        b = &b[size + 2..];
    }
}

/// One request, answered whole within `timeout`.
pub fn request(url: &Url, method: &str, path: &str, headers: &[(&str, &str)], body: Option<&[u8]>, timeout: Duration) -> Result<Response, Failure> {
    let deadline = Instant::now() + timeout;
    let addrs: Vec<_> = url.authority.to_socket_addrs().map_err(|e| Failure::Unreachable(format!("{}: {e}", url.authority)))?.collect();
    let mut last = String::from("no address");
    let mut stream = None;
    for a in &addrs {
        match TcpStream::connect_timeout(a, timeout.min(Duration::from_secs(10))) {
            Ok(s) => {
                stream = Some(s);
                break;
            }
            Err(e) => last = format!("{a}: {e}"),
        }
    }
    let mut s = stream.ok_or(Failure::Unreachable(last))?;
    let left = || deadline.saturating_duration_since(Instant::now()).max(Duration::from_millis(1));
    let _ = s.set_read_timeout(Some(left()));
    let _ = s.set_write_timeout(Some(left()));
    let mut head = format!("{method} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nUser-Agent: wai-conform\r\n", url.join(path), url.authority);
    for (k, v) in headers {
        head.push_str(&format!("{k}: {v}\r\n"));
    }
    if let Some(b) = body {
        head.push_str(&format!("Content-Length: {}\r\n", b.len()));
    }
    head.push_str("\r\n");
    let broken = |e: std::io::Error| Failure::Broken(format!("{method} {path}: {e}"));
    s.write_all(head.as_bytes()).map_err(broken)?;
    if let Some(b) = body {
        s.write_all(b).map_err(broken)?;
    }
    let mut buf = Vec::new();
    let mut chunk = [0u8; 64 * 1024];
    loop {
        let _ = s.set_read_timeout(Some(left()));
        match s.read(&mut chunk) {
            Ok(0) => break,
            Ok(n) => {
                buf.extend_from_slice(&chunk[..n]);
                if buf.len() > MAX_RESPONSE {
                    return Err(Failure::Broken(format!("{method} {path}: the response is larger than {MAX_RESPONSE} bytes")));
                }
            }
            Err(e) if Instant::now() >= deadline => return Err(Failure::Broken(format!("{method} {path}: no complete response in {timeout:?} ({e})"))),
            Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted) => {}
            Err(e) => {
                if buf.is_empty() {
                    return Err(broken(e));
                }
                break;
            }
        }
    }
    let end = buf.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| Failure::Broken(format!("{method} {path}: no response head")))?;
    let head = String::from_utf8_lossy(&buf[..end]).into_owned();
    let mut lines = head.split("\r\n");
    let status_line = lines.next().unwrap_or("");
    let status: u16 = status_line.split(' ').nth(1).and_then(|s| s.parse().ok()).ok_or_else(|| Failure::Broken(format!("{method} {path}: status line {status_line:?}")))?;
    let headers: Vec<(String, String)> = lines.filter_map(|l| l.split_once(':').map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))).collect();
    let mut body = buf[end + 4..].to_vec();
    let r = Response { status, headers, body: Vec::new() };
    if method == "HEAD" || status == 204 || status == 304 {
        body.clear();
    } else if r.header("Transfer-Encoding").is_some_and(|t| t.to_ascii_lowercase().contains("chunked")) {
        body = dechunk(&body).ok_or_else(|| Failure::Broken(format!("{method} {path}: a chunked body that does not read")))?;
    } else if let Some(n) = r.header("Content-Length").and_then(|v| v.trim().parse::<usize>().ok()) {
        if body.len() < n {
            return Err(Failure::Broken(format!("{method} {path}: {} of {n} body bytes", body.len())));
        }
        body.truncate(n);
    }
    Ok(Response { body, ..r })
}
