//! 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, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::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,
        }
    }
}

fn handle_connection(mut stream: TcpStream, state: &MetricsState) {
    // Set a read timeout to avoid hanging on slow/malicious clients.
    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));

    let mut reader = BufReader::new(&stream);
    let mut request_line = String::new();
    if reader.read_line(&mut request_line).is_err() {
        return;
    }
    // Drain remaining headers to avoid broken pipe.
    let mut header = String::new();
    loop {
        header.clear();
        match reader.read_line(&mut header) {
            Ok(0) | Err(_) => break,
            Ok(n) if n <= 2 => break, // empty line = end of headers
            _ => {}
        }
    }

    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(),
        ),
    };

    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"
    let parts: Vec<&str> = request_line.split_whitespace().collect();
    if parts.len() >= 2 { parts[1] } else { "/" }
}
