//! # openfut-redirector-host
//!
//! Transport host for the FIFA 17 Blaze redirector — the first hop.
//!
//! ```text
//!   FIFA 17 ──TLS 1.2, static-RSA──> this host ──> <serverinstanceinfo>
//!                                                   "connect to <adv>:<blaze_port>"
//! ```
//!
//! ## A deliberately small compatibility island
//!
//! This is the only OpenFUT crate that links OpenSSL, and it does so because a
//! retail FIFA 17 client offers exactly eight static-RSA suites and nothing
//! forward-secret. Neither OpenFUT Core nor the generic protocol crates gain
//! that dependency. See [`tls`] for what the observed ClientHello dictates.
//!
//! ## Division of responsibility
//!
//! The host owns the listener, TLS, HTTP framing, connection lifecycle and
//! diagnostics. `openfut-adapter-fifa17::redirector` owns the response and
//! nothing else — the same split as the Blaze sidecar, where the adapter
//! decides what to say and the host owns the socket.
//!
//! No FUT state lives here.
//!
//! ## Configuration
//!
//! Resolved through `openfut-host-config`, the single environment reader, so
//! the advertised address reaches this host by the same construction path as
//! the Blaze sidecar's.

pub mod config;

use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use openfut_adapter_fifa17::redirector;
// Body draining is shared. Behaviour that must be identical across hosts gets
// one implementation, for the same reason TLS does: the divergence it guards
// against (an unread body turning the close into an RST) is invisible in any
// comparison of the response, and cost two live gate attempts to find.
use openfut_http::drain_body;
pub use openfut_http::BodyRead;
use openssl::ssl::SslAcceptor;

pub use config::RedirectorConfig;

fn log(msg: &str) {
    let ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0);
    eprintln!("[{}.{:03}] {msg}", ms / 1000, ms % 1000);
}

/// The commit this binary was built from.
///
/// Only the commit: a compiled-in cleanliness claim can go stale (cargo will
/// not re-run a build script for another crate's edit), so the authoritative
/// comparison happens at launch in `scripts/verify-build-identity.sh`.
pub const BUILD_COMMIT: &str = env!("OPENFUT_BUILD_COMMIT");

/// Build identity, printable without any configuration.
///
/// `--identity` exists so the launcher can establish which commit a binary came
/// from before deciding whether to run it. Machine-readable `key=value`.
pub fn identity() -> String {
    format!(
        "openfut-redirector-host v{} commit={} profile={} openssl={}",
        env!("CARGO_PKG_VERSION"),
        BUILD_COMMIT,
        if cfg!(debug_assertions) {
            "debug"
        } else {
            "release"
        },
        openfut_tls::openssl_version(),
    )
}

/// One line naming the binary, its linked TLS, and what it will advertise.
///
/// Machine-readable `key=value` so the launcher can extract the commit without
/// guessing at prose.
pub fn banner(cfg: &RedirectorConfig) -> String {
    format!(
        "openfut-redirector-host v{} commit={} profile={} openssl={} listen={} \
         advertise={}:{} tls_min={} tls_max={} security_level={} cert_sha256={} ciphers={}",
        env!("CARGO_PKG_VERSION"),
        BUILD_COMMIT,
        if cfg!(debug_assertions) {
            "debug"
        } else {
            "release"
        },
        openfut_tls::openssl_version(),
        cfg.listen_on(),
        cfg.adapter.endpoints.advertise,
        cfg.adapter.endpoints.blaze_port,
        // Readable names, not `SslVersion(771)`: this line is gate evidence and
        // is read by humans comparing one run against another.
        cfg.tls.min_version.as_str(),
        cfg.tls.max_version.as_str(),
        cfg.tls
            .security_level
            .map(|l| l.to_string())
            .unwrap_or_else(|| "default".into()),
        // WHICH certificate, not just that one loaded. Serving a different
        // certificate from the rest of the stack raises no error at startup and
        // breaks the client much later, with nothing logged anywhere. Printing
        // it unprompted is what turns that into a one-glance comparison.
        openfut_tls::certificate_fingerprint(&cfg.tls.cert_path)
            .unwrap_or_else(|e| format!("unreadable ({e})")),
        cfg.tls.cipher_list,
    )
}

/// A bound listener, so a caller can learn the real port before serving
/// (ephemeral ports in tests) and so the self-test runs before anything is
/// accepted.
pub struct Server {
    pub local_addr: std::net::SocketAddr,
    listener: TcpListener,
    acceptor: Arc<SslAcceptor>,
    cfg: Arc<RedirectorConfig>,
    outcomes: Outcomes,
    probes: ProbeCount,
}

/// How many bare port probes have been classified.
///
/// Counted rather than only logged for the same reason [`ConnOutcome`] exists:
/// a test that asserts on client-visible symptoms cannot tell a probe that was
/// classified from one that merely failed quietly, so removing the
/// classification would leave the suite green. This makes it assertable.
pub type ProbeCount = Arc<std::sync::atomic::AtomicUsize>;

/// What the host did on a connection, beyond the bytes it sent.
///
/// Recorded because the failure that cost two live gate attempts — a request
/// body left unread — is invisible to any comparison of the response. The log
/// line carries the same facts for a live run; this makes them assertable.
#[derive(Debug, Clone)]
pub struct ConnOutcome {
    pub id: u64,
    pub request_bytes: usize,
    pub body: BodyRead,
    pub response_bytes: usize,
}

pub type Outcomes = Arc<std::sync::Mutex<Vec<ConnOutcome>>>;

/// Keep the record bounded: a long-running redirector must not accumulate one
/// entry per connection forever.
const OUTCOME_HISTORY: usize = 64;

impl Server {
    /// A handle to the bare-probe counter, obtainable before [`Server::run`].
    pub fn probes(&self) -> ProbeCount {
        self.probes.clone()
    }

    /// A handle to the connection record, obtainable before [`Server::run`]
    /// consumes the server.
    pub fn outcomes(&self) -> Outcomes {
        self.outcomes.clone()
    }

    pub fn run(self) -> std::io::Result<()> {
        let counter = AtomicU64::new(0);
        for incoming in self.listener.incoming() {
            let Ok(stream) = incoming else { continue };
            let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
            let (acceptor, cfg) = (self.acceptor.clone(), self.cfg.clone());
            let outcomes = self.outcomes.clone();
            let probes = self.probes.clone();
            std::thread::spawn(move || handle(stream, id, &acceptor, &cfg, &outcomes, &probes));
        }
        Ok(())
    }
}

fn record(outcomes: &Outcomes, o: ConnOutcome) {
    if let Ok(mut v) = outcomes.lock() {
        if v.len() >= OUTCOME_HISTORY {
            v.remove(0);
        }
        v.push(o);
    }
}

/// Build TLS, rehearse the retail handshake, and bind.
pub fn bind(cfg: RedirectorConfig) -> std::io::Result<Server> {
    let acceptor = openfut_tls::build_acceptor(&cfg.tls)
        .map_err(|e| std::io::Error::other(format!("TLS setup failed: {e}")))?;
    // Rehearse the retail handshake BEFORE accepting anything: a client
    // restricted to exactly the suites FIFA offers must connect. Catching a
    // cipher/version misconfiguration here means it never shows up as an
    // unexplained failure during a live gate.
    let neg = openfut_tls::self_test(
        &cfg.tls,
        openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES,
        openfut_adapter_fifa17::tls::CLIENT_SNI,
    )
    .map_err(|e| {
        std::io::Error::other(format!(
            "self-test failed — a FIFA-like client cannot connect: {e}"
        ))
    })?;
    log(&format!(
        "SELF-TEST OK: a FIFA-like client negotiates {} / {}",
        neg.version, neg.cipher
    ));

    let listener = TcpListener::bind(cfg.listen_on())?;
    let local_addr = listener.local_addr()?;
    log(&banner(&cfg));
    Ok(Server {
        local_addr,
        listener,
        acceptor: Arc::new(acceptor),
        cfg: Arc::new(cfg),
        outcomes: Outcomes::default(),
        probes: ProbeCount::default(),
    })
}

/// Serve until the process is killed.
pub fn serve(cfg: RedirectorConfig) -> std::io::Result<()> {
    bind(cfg)?.run()
}

/// What a peer did with the connection before any TLS was attempted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeerOpening {
    /// Connected and closed without sending anything: a reachability probe.
    ClosedWithoutSpeaking,
    /// Sent at least one byte, so a real handshake is under way.
    Spoke,
    /// Timed out or errored. Deliberately NOT treated as a probe — a slow or
    /// broken client must still reach the acceptor and produce a real
    /// diagnostic, because misclassifying a fault as a probe would hide
    /// precisely what this distinction exists to protect.
    Undetermined,
}

/// Classify the result of peeking at the first byte.
///
/// Split out as a pure function so the policy is testable without a socket —
/// the interesting cases (EOF vs timeout) are awkward to provoke live and easy
/// to get backwards.
pub fn classify_opening(peek: &std::io::Result<usize>) -> PeerOpening {
    match peek {
        Ok(0) => PeerOpening::ClosedWithoutSpeaking,
        Ok(_) => PeerOpening::Spoke,
        Err(_) => PeerOpening::Undetermined,
    }
}

fn handle(
    stream: TcpStream,
    id: u64,
    acceptor: &SslAcceptor,
    cfg: &RedirectorConfig,
    outcomes: &Outcomes,
    probes: &ProbeCount,
) {
    let peer = stream
        .peer_addr()
        .map(|a| a.to_string())
        .unwrap_or_else(|_| "<unknown>".into());
    let _ = stream.set_read_timeout(Some(Duration::from_secs(15)));
    let _ = stream.set_write_timeout(Some(Duration::from_secs(15)));

    // Classify a bare port probe BEFORE the acceptor sees it.
    //
    // A reachability probe — the launcher's preflight makes two per run, and
    // any `TcpStream::connect` then drop is one — opens the connection and
    // closes without sending a byte. Once that reaches the acceptor it fails
    // as "unexpected EOF", which is byte-identical to the signature of a
    // genuine TLS fault. That is not a cosmetic problem: "unexpected EOF" is
    // exactly how the certificate mismatch that cost three live gates
    // presented, so a benign probe forging it poisons the one channel this
    // project gates on. Classified here, the two are never confused again.
    let mut first = [0u8; 1];
    if classify_opening(&stream.peek(&mut first)) == PeerOpening::ClosedWithoutSpeaking {
        probes.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        log(&format!(
            "conn-{id:04} {peer} PROBE: closed before sending a ClientHello (not a TLS fault)"
        ));
        return;
    }

    let mut tls = match acceptor.accept(stream) {
        Ok(s) => s,
        Err(e) => {
            // The most valuable diagnostic this host produces: a handshake
            // failure names the client and the reason, so a cipher/version
            // mismatch is obvious rather than looking like a network fault.
            log(&format!("conn-{id:04} {peer} TLS HANDSHAKE FAILED: {e}"));
            return;
        }
    };

    // Exactly what the retail client negotiated. Recorded per connection
    // because it is the evidence a gate is judged on.
    {
        let s = tls.ssl();
        log(&format!(
            "conn-{id:04} {peer} TLS OK version={} cipher={} sni={}",
            s.version_str(),
            s.current_cipher().map(|c| c.name()).unwrap_or("?"),
            s.servername(openssl::ssl::NameType::HOST_NAME)
                .unwrap_or("<none>")
        ));
    }

    // Read the request head. The oracle answers any request with the same body,
    // so this is parsed for diagnostics, not for routing — a redirector that
    // started 404ing unexpected paths would be a behaviour change, not a fix.
    let mut buf = Vec::new();
    let mut chunk = [0u8; 4096];
    loop {
        match tls.read(&mut chunk) {
            Ok(0) => break,
            Ok(n) => {
                buf.extend_from_slice(&chunk[..n]);
                if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > 65536 {
                    break;
                }
            }
            Err(e) => {
                log(&format!("conn-{id:04} {peer} read failed: {e}"));
                return;
            }
        }
    }

    // Drain the request body, exactly as the oracle does. This is NOT cosmetic:
    // answering and closing while the client is still sending leaves unread data
    // in the receive queue, and Linux turns that close into an RST rather than a
    // FIN. The oracle has always drained; the first version of this host stopped
    // at the header terminator, which is a behaviour difference invisible to any
    // byte-comparison of the response.
    let body_read = drain_body(&mut tls, &mut buf);

    let head = String::from_utf8_lossy(&buf);
    let line0 = head.lines().next().unwrap_or("").to_string();
    log(&format!(
        "conn-{id:04} {peer} REQ {line0} req_bytes={} body={}",
        buf.len(),
        body_read.describe()
    ));
    if !line0.is_empty() && !redirector::is_get_server_instance(&line0) {
        log(&format!(
            "conn-{id:04} {peer} NOTE: unexpected request line; answering anyway (oracle behaviour)"
        ));
    }

    let response = redirector::redirect_response(&cfg.adapter);
    if let Err(e) = tls.write_all(&response) {
        log(&format!("conn-{id:04} {peer} write failed: {e}"));
        return;
    }
    let _ = tls.flush();
    log(&format!(
        "conn-{id:04} {peer} SENT {}B serverinstanceinfo -> {}:{}",
        response.len(),
        cfg.adapter.endpoints.advertise,
        cfg.adapter.endpoints.blaze_port
    ));

    // The oracle holds the connection open before closing. That dwell is the
    // only other measured difference between the two implementations, and a
    // redirector that closes at 0ms is not the behaviour FIFA 17 was proven
    // against — so it is reproduced rather than assumed harmless.
    record(
        outcomes,
        ConnOutcome {
            id,
            request_bytes: buf.len(),
            body: body_read,
            response_bytes: response.len(),
        },
    );

    std::thread::sleep(cfg.close_dwell);
    let _ = tls.shutdown();
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn banner_names_the_linked_openssl_and_what_is_advertised() {
        let cfg = RedirectorConfig::for_test("198.51.100.7");
        let b = banner(&cfg);
        assert!(b.contains("openssl="), "{b}");
        assert!(b.contains("commit="), "{b}");
        assert!(b.contains("tls_min="), "{b}");
        assert!(b.contains("198.51.100.7"), "{b}");
        // The cipher list is part of the identity of a compatibility host.
        assert!(b.contains("AES256-GCM-SHA384"), "{b}");
    }
}
