//! Optional HTTP metrics server for tidalDB.
//!
//! Disabled by default. Enable with the `metrics` feature flag.
//!
//! Serves two endpoints on a background `std::thread`:
//! - `GET /healthz` -- JSON health check
//! - `GET /metrics` -- Prometheus text exposition format
//!
//! The server reads from [`MetricsState`] which is Arc-shared with [`TidalDb`].
//! The server thread exits cleanly when [`MetricsHandle::stop`] is called.

use std::{
    io::{BufRead, BufReader, Read, Write},
    net::{TcpListener, TcpStream},
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    },
    thread,
};

use super::metrics::MetricsState;

/// Handle to the background metrics HTTP server.
///
/// The server thread runs until [`stop`] is called or this handle is dropped.
///
/// # Examples
///
/// ```no_run
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// // Enable the metrics server when opening the database:
/// let db = tidaldb::TidalDb::builder()
///     .ephemeral()
///     .enable_metrics("127.0.0.1:0")
///     .open()?;
///
/// // Discover the OS-assigned port when "0" was requested:
/// if let Some(addr) = db.metrics_addr() {
///     println!("GET http://{addr}/metrics  (Prometheus)");
///     println!("GET http://{addr}/healthz  (JSON)");
/// }
///
/// // Shutdown: server stops when `db` is dropped or `db.close()` is called.
/// db.close()?;
/// # Ok(())
/// # }
/// ```
pub struct MetricsHandle {
    shutdown: Arc<AtomicBool>,
    thread: Option<thread::JoinHandle<()>>,
    /// The actual bound address (useful when port 0 was requested).
    pub addr: std::net::SocketAddr,
}

impl MetricsHandle {
    /// Bind to the given address and start the background server thread.
    ///
    /// # Errors
    ///
    /// Returns `std::io::Error` if the address cannot be bound.
    pub fn start(addr: &str, state: Arc<MetricsState>) -> std::io::Result<Self> {
        // Warn if the metrics endpoint is bound to a non-loopback address.
        // The /metrics endpoint is unauthenticated; exposing it externally
        // without firewall protection is a security risk.
        if let Ok(parsed) = addr.parse::<std::net::SocketAddr>()
            && !parsed.ip().is_loopback()
        {
            tracing::warn!(
                address = %parsed,
                "tidalDB metrics server bound to non-loopback address -- \
                 the /metrics endpoint is unauthenticated; ensure firewall protection"
            );
        }

        let listener = TcpListener::bind(addr)?;
        let actual_addr = listener.local_addr()?;
        let shutdown = Arc::new(AtomicBool::new(false));
        let shutdown_clone = Arc::clone(&shutdown);

        // listener, state, and shutdown_clone are moved into the thread --
        // they must be owned. serve_loop takes them by value intentionally.
        let handle = thread::Builder::new()
            .name("tidaldb-metrics-http".into())
            .spawn(move || serve_loop(listener, state, shutdown_clone))
            .map_err(std::io::Error::other)?;

        Ok(Self {
            shutdown,
            thread: Some(handle),
            addr: actual_addr,
        })
    }

    /// Stop the metrics server. Blocks until the server thread exits.
    pub fn stop(&mut self) {
        self.shutdown.store(true, Ordering::Release);
        // Connect to ourselves to unblock the accept() call.
        if let Ok(mut conn) = TcpStream::connect(self.addr) {
            let _ = conn.write_all(b"");
        }
        if let Some(t) = self.thread.take() {
            let _ = t.join();
        }
    }
}

impl Drop for MetricsHandle {
    fn drop(&mut self) {
        self.stop();
    }
}

// These parameters are passed by value intentionally: they are moved into the
// spawned thread closure. Taking references would require `'static` lifetimes
// which aren't available from the caller's stack.
#[allow(clippy::needless_pass_by_value)]
fn serve_loop(listener: TcpListener, state: Arc<MetricsState>, shutdown: Arc<AtomicBool>) {
    let timeout = std::time::Duration::from_millis(50);
    // Use non-blocking + sleep to periodically check the shutdown flag
    // without blocking indefinitely in accept().
    if let Err(e) = listener.set_nonblocking(true) {
        tracing::warn!(error = %e, "failed to set non-blocking mode on metrics listener");
    }

    loop {
        if shutdown.load(Ordering::Acquire) {
            break;
        }
        match listener.accept() {
            Ok((stream, _)) => {
                if shutdown.load(Ordering::Acquire) {
                    break;
                }
                handle_connection(stream, &state);
            }
            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                thread::sleep(timeout);
            }
            Err(_) => break,
        }
    }
}

/// Maximum bytes accepted for the HTTP request line (`METHOD PATH VERSION`).
///
/// This endpoint serves only `/healthz` and `/metrics` GETs, so a legitimate
/// request line is well under 64 bytes. An 8 KiB cap is generous yet bounds the
/// per-connection allocation, so a malicious client cannot drive unbounded
/// `String` growth by sending a multi-gigabyte request line with no newline.
const MAX_REQUEST_LINE_BYTES: u64 = 8 * 1024;

/// Maximum bytes accepted for a single header line.
///
/// Headers are drained but never parsed; this just bounds per-line growth.
const MAX_HEADER_LINE_BYTES: u64 = 16 * 1024;

/// Maximum number of header lines drained before giving up.
///
/// Together with [`MAX_HEADER_LINE_BYTES`] this bounds total header bytes a
/// single connection can force us to read.
const MAX_HEADER_LINES: usize = 100;

/// Outcome of the bounded request read.
enum ReadOutcome {
    /// Successfully read the request line (headers drained).
    Ok(String),
    /// The connection errored before a full request line was read.
    Closed,
    /// The request line exceeded [`MAX_REQUEST_LINE_BYTES`] with no newline.
    TooLarge,
}

/// Read and bound-check the request line, then drain (bounded) headers.
///
/// All reading happens here so the `BufReader` borrow of `stream` is released
/// before the caller writes a response back to the same stream.
fn read_bounded_request(stream: &TcpStream) -> ReadOutcome {
    let mut reader = BufReader::new(stream);

    // Bound the request line: take(N) caps how many bytes read_line will pull
    // into the String, so an oversize / newline-less request line is rejected
    // instead of growing the buffer without limit.
    let mut request_line = String::new();
    if (&mut reader)
        .take(MAX_REQUEST_LINE_BYTES)
        .read_line(&mut request_line)
        .is_err()
    {
        return ReadOutcome::Closed;
    }
    // Reject an over-length request line (no terminating newline within the
    // cap): treat it as a malformed/oversize request.
    if request_line.len() as u64 >= MAX_REQUEST_LINE_BYTES && !request_line.ends_with('\n') {
        return ReadOutcome::TooLarge;
    }

    // Drain remaining headers to avoid broken pipe. Each header line is bounded
    // by MAX_HEADER_LINE_BYTES and the total count by MAX_HEADER_LINES so a
    // client cannot stream unlimited header bytes at us.
    let mut header = String::new();
    let mut header_lines = 0usize;
    loop {
        if header_lines >= MAX_HEADER_LINES {
            break;
        }
        header.clear();
        match (&mut reader)
            .take(MAX_HEADER_LINE_BYTES)
            .read_line(&mut header)
        {
            Ok(0) | Err(_) => break,
            Ok(n) if n <= 2 => break, // empty line = end of headers
            _ => header_lines += 1,
        }
    }

    ReadOutcome::Ok(request_line)
}

fn handle_connection(mut stream: TcpStream, state: &MetricsState) {
    // Force the accepted stream into BLOCKING mode before reading.
    //
    // `serve_loop` puts the *listener* in non-blocking mode so it can poll the
    // shutdown flag between `accept()` calls. On BSD-derived platforms (macOS)
    // an accepted stream INHERITS the listener's non-blocking flag, and on a
    // non-blocking socket the `set_read_timeout` below is silently ignored:
    // `read_line` returns `WouldBlock` the instant the request bytes have not
    // yet been delivered to the kernel receive buffer. Under scheduler load that
    // races the client's write — the server reads nothing, returns without
    // responding, and drops the socket while the client's request is still in
    // flight, so the kernel sends an RST and the client sees a reset / empty
    // body (the source of the intermittent metrics-integration failures).
    // Resetting to blocking makes the read timeout authoritative and the read
    // deterministic regardless of platform accept-inheritance semantics.
    let _ = stream.set_nonblocking(false);

    // Set a read timeout to avoid hanging on slow/malicious clients.
    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));

    // Read in a separate function so the reader's borrow of `stream` is dropped
    // before we write the response back to it.
    let request_line = match read_bounded_request(&stream) {
        ReadOutcome::Ok(line) => line,
        ReadOutcome::Closed => return,
        ReadOutcome::TooLarge => {
            write_response(
                &mut stream,
                "431 Request Header Fields Too Large",
                "application/json",
                r#"{"error":"request line too large"}"#,
            );
            return;
        }
    };

    let path = extract_path(&request_line);
    let (status, content_type, body) = match path {
        "/healthz" => ("200 OK", "application/json", state.render_healthz()),
        "/metrics" => (
            "200 OK",
            "text/plain; version=0.0.4; charset=utf-8",
            state.render_prometheus(),
        ),
        _ => (
            "404 Not Found",
            "application/json",
            r#"{"error":"not found"}"#.to_string(),
        ),
    };

    write_response(&mut stream, status, content_type, &body);
}

/// Write a complete HTTP/1.1 response with `Connection: close`.
fn write_response(stream: &mut TcpStream, status: &str, content_type: &str, body: &str) {
    let response = format!(
        "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    );
    let _ = stream.write_all(response.as_bytes());
}

fn extract_path(request_line: &str) -> &str {
    // "GET /path HTTP/1.1" -- take the second whitespace-delimited token without
    // allocating a Vec of all tokens just to index position 1.
    request_line.split_whitespace().nth(1).unwrap_or("/")
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    // `Read` (for read_to_string) and the net/thread types come in via super::*.
    use super::*;

    /// Drive `handle_connection` over a real loopback socket with a client
    /// payload, returning the server's raw response bytes.
    fn drive(payload: &[u8]) -> String {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let state = Arc::new(MetricsState::new());

        let server = thread::spawn(move || {
            let (stream, _) = listener.accept().unwrap();
            handle_connection(stream, &state);
        });

        let mut client = TcpStream::connect(addr).unwrap();
        client.write_all(payload).unwrap();
        // Half-close so the server's bounded read terminates deterministically.
        client.shutdown(std::net::Shutdown::Write).unwrap();

        let mut resp = String::new();
        // Read timeout guards against a hang if the bound ever regresses.
        client
            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
            .unwrap();
        let _ = client.read_to_string(&mut resp);
        server.join().unwrap();
        resp
    }

    #[test]
    fn well_formed_request_is_served() {
        let resp = drive(b"GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n");
        assert!(resp.starts_with("HTTP/1.1 200 OK"), "response: {resp:?}");
        assert!(resp.contains("\"status\":"), "response: {resp:?}");
    }

    #[test]
    fn oversize_request_line_is_rejected_not_buffered() {
        // A request line far longer than the cap, with NO terminating newline.
        // The bounded read must stop at MAX_REQUEST_LINE_BYTES and the server
        // must answer 431 rather than buffer the whole payload or hang.
        let mut payload = b"GET /".to_vec();
        payload.extend(std::iter::repeat_n(
            b'a',
            (MAX_REQUEST_LINE_BYTES as usize) + 4096,
        ));
        let resp = drive(&payload);
        assert!(
            resp.starts_with("HTTP/1.1 431"),
            "oversize request line must be rejected with 431, got: {}",
            resp.chars().take(64).collect::<String>()
        );
    }

    #[test]
    fn flood_of_header_lines_is_bounded() {
        // Far more header lines than MAX_HEADER_LINES; the drain loop must stop
        // and still serve the (valid) request line rather than reading forever.
        let mut payload = b"GET /metrics HTTP/1.1\r\n".to_vec();
        for i in 0..(MAX_HEADER_LINES + 500) {
            payload.extend(format!("X-H-{i}: v\r\n").into_bytes());
        }
        payload.extend_from_slice(b"\r\n");
        let resp = drive(&payload);
        // The request line was valid and read first, so we still get 200; the
        // point is the call returns promptly instead of draining unboundedly.
        assert!(resp.starts_with("HTTP/1.1 200 OK"), "response: {resp:?}");
    }
}
