//! Legacy-TLS listeners for OpenFUT transport hosts.
//!
//! # Why this crate exists
//!
//! Every OpenFUT host that faces a game client needs the same thing: an
//! OpenSSL acceptor configured for a client far older than the defaults any
//! modern TLS stack ships. Before this crate there was exactly one such host, so
//! its TLS lived inside it. The moment a second host needed the same setup, the
//! choice was to share this code or copy it.
//!
//! Copying it is not a style question. On 2026-08-11 the Rust redirector served
//! one certificate while the rest of the stack served another. FIFA's ProtoSSL
//! caches the server certificate per backend, so the first connection of a
//! session decided what the client expected and every later service failed its
//! handshake — silently, because Python's `socketserver` swallows `ssl.SSLError`
//! as `OSError` and logs nothing. Three gates were lost to it. One place to
//! configure TLS is the structural fix.
//!
//! # What belongs here, and what does not
//!
//! This crate is **game-independent**. It knows how to build an acceptor, not
//! which suites any particular client offers. Cipher lists, protocol windows,
//! the SNI a client sends, and which suites are deliberately refused are facts
//! about a *game*, and live in that game's adapter — see
//! `openfut-adapter-fifa17`'s `tls` module. Values arrive here as plain data so
//! adapters never take an OpenSSL dependency.
//!
//! # Scope of every setting
//!
//! Everything configured here applies to the `SslContext` being built. Nothing
//! global is weakened, and no other OpenFUT crate links OpenSSL.

use std::fmt;
use std::path::Path;

use openssl::ssl::{SslFiletype, SslOptions, SslVersion};

/// Re-exported so a transport host never has to name OpenSSL itself.
///
/// Hosts depend on this crate, not on `openssl`. That keeps the linked TLS
/// implementation a property of one manifest rather than of every host — the
/// version is gate evidence, and a host that could pull its own would be able
/// to drift from the rest of the stack silently.
pub use openssl::ssl::{SslAcceptor, SslConnector, SslMethod, SslStream, SslVerifyMode};

#[derive(Debug)]
pub struct TlsError(pub String);

impl fmt::Display for TlsError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for TlsError {}

/// A TLS protocol version, expressed without an OpenSSL type.
///
/// Adapters name the version window their client was observed to use; keeping
/// that expressible as plain data is what lets them stay dependency-free.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolVersion {
    Tls10,
    Tls11,
    Tls12,
    Tls13,
}

impl ProtocolVersion {
    /// Parse OpenSSL's own spelling, as reported by a handshake.
    ///
    /// Deliberately strict: an unrecognised version is an error, never a
    /// silent fallback to a default. Guessing here would mean serving a
    /// protocol window nobody chose.
    pub fn parse(s: &str) -> Result<ProtocolVersion, TlsError> {
        match s.trim() {
            "TLSv1" | "TLSv1.0" => Ok(ProtocolVersion::Tls10),
            "TLSv1.1" => Ok(ProtocolVersion::Tls11),
            "TLSv1.2" => Ok(ProtocolVersion::Tls12),
            "TLSv1.3" => Ok(ProtocolVersion::Tls13),
            other => Err(TlsError(format!("unknown TLS version {other:?}"))),
        }
    }

    pub fn as_str(self) -> &'static str {
        match self {
            ProtocolVersion::Tls10 => "TLSv1.0",
            ProtocolVersion::Tls11 => "TLSv1.1",
            ProtocolVersion::Tls12 => "TLSv1.2",
            ProtocolVersion::Tls13 => "TLSv1.3",
        }
    }

    fn to_ssl(self) -> SslVersion {
        match self {
            ProtocolVersion::Tls10 => SslVersion::TLS1,
            ProtocolVersion::Tls11 => SslVersion::TLS1_1,
            ProtocolVersion::Tls12 => SslVersion::TLS1_2,
            ProtocolVersion::Tls13 => SslVersion::TLS1_3,
        }
    }
}

/// TLS knobs, all overridable so a failed retail handshake is a configuration
/// change rather than a code change.
#[derive(Debug, Clone)]
pub struct TlsConfig {
    pub cert_path: String,
    pub key_path: String,
    pub cipher_list: String,
    pub min_version: ProtocolVersion,
    pub max_version: ProtocolVersion,
    /// `None` = leave OpenSSL's default policy alone. Only set this if a real
    /// handshake proves it necessary, and record why. Lowering the security
    /// level pre-emptively weakens the service to fix a problem nobody has
    /// demonstrated.
    pub security_level: Option<u32>,
}

impl TlsConfig {
    /// Build a config from an adapter's observed profile.
    ///
    /// There is intentionally no `Default`: the cipher list and version window
    /// are evidence about a specific client, and a generic crate has no
    /// business inventing them.
    pub fn new(
        cert_path: impl Into<String>,
        key_path: impl Into<String>,
        cipher_list: impl Into<String>,
        min_version: ProtocolVersion,
        max_version: ProtocolVersion,
    ) -> TlsConfig {
        TlsConfig {
            cert_path: cert_path.into(),
            key_path: key_path.into(),
            cipher_list: cipher_list.into(),
            min_version,
            max_version,
            security_level: None,
        }
    }
}

/// The OpenSSL version this binary is linked against.
///
/// Vendored, so it is fixed at build time rather than inherited from the host.
/// Printed at startup and recorded in gate evidence.
pub fn openssl_version() -> String {
    openssl::version::version().to_string()
}

/// Build a listener acceptor from `cfg`.
pub fn build_acceptor(cfg: &TlsConfig) -> Result<SslAcceptor, TlsError> {
    // `mozilla_intermediate` preloads a modern, forward-secret-only policy —
    // precisely wrong for a legacy client. It is used only as a starting
    // builder; every setting that matters is then stated explicitly below, so
    // nothing is inherited silently.
    let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls())
        .map_err(|e| TlsError(format!("acceptor: {e}")))?;

    b.set_min_proto_version(Some(cfg.min_version.to_ssl()))
        .map_err(|e| TlsError(format!("min proto: {e}")))?;
    b.set_max_proto_version(Some(cfg.max_version.to_ssl()))
        .map_err(|e| TlsError(format!("max proto: {e}")))?;

    // Explicit list, replacing whatever policy the profile brought with it.
    b.set_cipher_list(&cfg.cipher_list)
        .map_err(|e| TlsError(format!("cipher list {:?}: {e}", cfg.cipher_list)))?;

    if let Some(level) = cfg.security_level {
        b.set_security_level(level);
    }

    // SSLv3 is never acceptable, whatever the version floor says.
    b.set_options(SslOptions::NO_SSLV3);

    let cert = Path::new(&cfg.cert_path);
    let key = Path::new(&cfg.key_path);
    if !cert.exists() {
        return Err(TlsError(format!(
            "certificate not found: {}",
            cfg.cert_path
        )));
    }
    if !key.exists() {
        return Err(TlsError(format!("private key not found: {}", cfg.key_path)));
    }
    b.set_certificate_chain_file(cert)
        .map_err(|e| TlsError(format!("certificate {}: {e}", cfg.cert_path)))?;
    b.set_private_key_file(key, SslFiletype::PEM)
        .map_err(|e| TlsError(format!("private key {}: {e}", cfg.key_path)))?;
    // Catches a mismatched pair at startup rather than mid-handshake, where it
    // would look like a client problem.
    b.check_private_key()
        .map_err(|e| TlsError(format!("certificate and key do not match: {e}")))?;

    Ok(b.build())
}

/// The SHA-256 fingerprint of the configured certificate, colon-separated.
///
/// Exists so a host can log which certificate it is serving, and so a caller
/// can compare hosts. Serving a *different* certificate from the rest of the
/// stack is not a visible error at startup — it fails much later, inside a
/// client that logs nothing — so the value is worth printing unprompted.
pub fn certificate_fingerprint(cert_path: &str) -> Result<String, TlsError> {
    let pem = std::fs::read(cert_path)
        .map_err(|e| TlsError(format!("reading certificate {cert_path}: {e}")))?;
    let cert = openssl::x509::X509::from_pem(&pem)
        .map_err(|e| TlsError(format!("parsing certificate {cert_path}: {e}")))?;
    let digest = cert
        .digest(openssl::hash::MessageDigest::sha256())
        .map_err(|e| TlsError(format!("fingerprint: {e}")))?;
    Ok(digest
        .iter()
        .map(|b| format!("{b:02X}"))
        .collect::<Vec<_>>()
        .join(":"))
}

/// What a handshake actually negotiated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Negotiated {
    pub version: String,
    pub cipher: String,
}

/// Handshake against ourselves with a client restricted to `client_ciphers`.
///
/// Enumerating the server's configured list would be the obvious check, but the
/// openssl crate exposes no such accessor — and a real handshake is better
/// evidence anyway: it proves MUTUAL support and reports what was actually
/// chosen, which is the thing a gate is judged on. Passing an adapter's
/// observed client suites makes this a direct rehearsal of the retail
/// handshake.
///
/// `sni` is the name the real client sends, so the rehearsal matches it.
pub fn self_test(cfg: &TlsConfig, client_ciphers: &str, sni: &str) -> Result<Negotiated, TlsError> {
    use openssl::ssl::{SslConnector, SslVerifyMode};
    use std::io::{Read, Write};
    use std::net::{TcpListener, TcpStream};

    let acceptor = build_acceptor(cfg)?;
    let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| TlsError(format!("bind: {e}")))?;
    let addr = listener
        .local_addr()
        .map_err(|e| TlsError(format!("addr: {e}")))?;

    let server = std::thread::spawn(move || -> Result<Negotiated, String> {
        let (sock, _) = listener.accept().map_err(|e| e.to_string())?;
        let mut tls = acceptor.accept(sock).map_err(|e| e.to_string())?;
        let neg = {
            let s = tls.ssl();
            Negotiated {
                version: s.version_str().to_string(),
                cipher: s
                    .current_cipher()
                    .map(|c| c.name().to_string())
                    .unwrap_or_default(),
            }
        };
        let mut buf = [0u8; 64];
        let _ = tls.read(&mut buf);
        let _ = tls.write_all(b"ok");
        let _ = tls.flush();
        Ok(neg)
    });

    let mut cb =
        SslConnector::builder(SslMethod::tls()).map_err(|e| TlsError(format!("connector: {e}")))?;
    cb.set_cipher_list(client_ciphers)
        .map_err(|e| TlsError(format!("client cipher list: {e}")))?;
    // The point is cipher/version negotiation, not PKI: the certificate is
    // self-signed and the real client's checks are patched out.
    cb.set_verify(SslVerifyMode::NONE);
    let connector = cb.build();

    let sock = TcpStream::connect(addr).map_err(|e| TlsError(format!("connect: {e}")))?;
    let mut client = connector
        .configure()
        .and_then(|c| c.verify_hostname(false).into_ssl(sni))
        .and_then(|ssl| openssl::ssl::SslStream::new(ssl, sock))
        .map_err(|e| TlsError(format!("client setup: {e}")))?;
    client
        .connect()
        .map_err(|e| TlsError(format!("client handshake failed: {e}")))?;
    let _ = client.write_all(b"ping");
    let _ = client.flush();
    let mut buf = [0u8; 8];
    let _ = client.read(&mut buf);

    server
        .join()
        .map_err(|_| TlsError("server thread panicked".into()))?
        .map_err(|e| TlsError(format!("server handshake failed: {e}")))
}

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

    /// A self-signed pair, generated here rather than borrowed from the game
    /// stack: this crate is game-independent and its tests must not depend on
    /// FIFA material. Adapter crates test their own profiles.
    ///
    /// Generated exactly once per process via `OnceLock`. The first version
    /// checked "does the cert file exist?" and returned early if so — which is
    /// a race, because the cert is written before the key. Tests run in
    /// parallel, so one could observe a certificate whose key had not landed
    /// yet and fail to build an acceptor. It failed in one run and passed in
    /// the next, which is exactly the kind of flake that gets rerun instead of
    /// fixed.
    fn keypair() -> (String, String) {
        use std::sync::OnceLock;
        static PAIR: OnceLock<(String, String)> = OnceLock::new();
        PAIR.get_or_init(|| generate_keypair("cert")).clone()
    }

    fn generate_keypair(tag: &str) -> (String, String) {
        use openssl::asn1::Asn1Time;
        use openssl::hash::MessageDigest;
        use openssl::pkey::PKey;
        use openssl::rsa::Rsa;
        use openssl::x509::{X509Name, X509};

        let dir =
            std::env::temp_dir().join(format!("openfut-tls-test-{}-{tag}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let cert_path = dir.join("cert.pem");
        let key_path = dir.join("key.pem");

        let rsa = Rsa::generate(2048).unwrap();
        let pkey = PKey::from_rsa(rsa).unwrap();
        let mut name = X509Name::builder().unwrap();
        name.append_entry_by_text("CN", "openfut-tls-test").unwrap();
        let name = name.build();

        let mut b = X509::builder().unwrap();
        b.set_version(2).unwrap();
        b.set_subject_name(&name).unwrap();
        b.set_issuer_name(&name).unwrap();
        b.set_pubkey(&pkey).unwrap();
        b.set_not_before(&Asn1Time::days_from_now(0).unwrap())
            .unwrap();
        b.set_not_after(&Asn1Time::days_from_now(3650).unwrap())
            .unwrap();
        b.sign(&pkey, MessageDigest::sha256()).unwrap();
        let cert = b.build();

        // Key first, then certificate: a reader that checks for the cert can
        // then never find one without its key.
        std::fs::write(&key_path, pkey.private_key_to_pem_pkcs8().unwrap()).unwrap();
        std::fs::write(&cert_path, cert.to_pem().unwrap()).unwrap();
        (
            cert_path.to_string_lossy().into(),
            key_path.to_string_lossy().into(),
        )
    }

    /// A legacy static-RSA profile, standing in for what an adapter supplies.
    fn cfg() -> TlsConfig {
        let (c, k) = keypair();
        TlsConfig::new(
            c,
            k,
            "AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA",
            ProtocolVersion::Tls12,
            ProtocolVersion::Tls12,
        )
    }

    #[test]
    fn an_acceptor_builds_from_a_valid_pair() {
        assert!(build_acceptor(&cfg()).is_ok());
    }

    #[test]
    fn a_static_rsa_client_completes_a_handshake() {
        let neg = self_test(&cfg(), "AES256-GCM-SHA384", "example.invalid").expect("handshake");
        assert_eq!(neg.cipher, "AES256-GCM-SHA384");
        assert_eq!(neg.version, "TLSv1.2");
    }

    /// The configured list is authoritative: a client offering only something
    /// outside it must fail. Without this, an acceptor that silently kept the
    /// modern default profile would look identical to a correct one.
    #[test]
    fn a_suite_outside_the_configured_list_is_refused() {
        let r = self_test(&cfg(), "ECDHE-RSA-AES256-GCM-SHA384", "example.invalid");
        assert!(r.is_err(), "expected refusal, got {r:?}");
    }

    #[test]
    fn the_protocol_window_is_enforced() {
        let (c, k) = keypair();
        let mut cfg = TlsConfig::new(
            c,
            k,
            "AES256-GCM-SHA384",
            ProtocolVersion::Tls12,
            ProtocolVersion::Tls12,
        );
        assert_eq!(
            self_test(&cfg, "AES256-GCM-SHA384", "x").unwrap().version,
            "TLSv1.2"
        );
        // Ceiling raised: TLS 1.3 has its own suites, so a 1.2-only client list
        // still lands on 1.2 — the window is a window, not a forced version.
        cfg.max_version = ProtocolVersion::Tls13;
        assert_eq!(
            self_test(&cfg, "AES256-GCM-SHA384", "x").unwrap().version,
            "TLSv1.2"
        );
    }

    #[test]
    fn a_missing_certificate_fails_clearly() {
        let mut c = cfg();
        c.cert_path = "/nonexistent/cert.pem".into();
        let e = match build_acceptor(&c) {
            Err(e) => e.to_string(),
            Ok(_) => panic!("a missing certificate must not build"),
        };
        assert!(e.contains("certificate not found"), "{e}");
    }

    #[test]
    fn a_mismatched_key_is_caught_at_startup() {
        let mut c = cfg();
        // A valid PEM that is not the key.
        c.key_path = c.cert_path.clone();
        assert!(build_acceptor(&c).is_err(), "mismatch must not build");
    }

    #[test]
    fn security_level_is_not_lowered_by_default() {
        assert_eq!(cfg().security_level, None);
    }

    #[test]
    fn versions_round_trip_and_unknown_ones_are_refused() {
        for v in [
            ProtocolVersion::Tls10,
            ProtocolVersion::Tls11,
            ProtocolVersion::Tls12,
            ProtocolVersion::Tls13,
        ] {
            assert_eq!(ProtocolVersion::parse(v.as_str()).unwrap(), v);
        }
        // OpenSSL reports 1.0 as bare "TLSv1"; accept what a handshake reports.
        assert_eq!(
            ProtocolVersion::parse("TLSv1").unwrap(),
            ProtocolVersion::Tls10
        );
        assert!(ProtocolVersion::parse("SSLv3").is_err());
        assert!(ProtocolVersion::parse("").is_err());
    }

    /// The fingerprint must be the certificate's, so two hosts can be compared.
    #[test]
    fn the_fingerprint_matches_openssl_and_differs_between_certificates() {
        let (c, _) = keypair();
        let fp = certificate_fingerprint(&c).expect("fingerprint");
        assert_eq!(fp.len(), 32 * 3 - 1, "32 hex pairs, colon separated: {fp}");
        assert!(fp.chars().all(|ch| ch.is_ascii_hexdigit() || ch == ':'));

        // A different certificate must produce a different fingerprint — this
        // is the whole point of the check that guards cross-host parity.
        // Generated through the same helper, so it cannot race either.
        let (other, _) = generate_keypair("alt");
        let fp2 = certificate_fingerprint(&other).unwrap();
        assert_ne!(fp, fp2);
    }

    #[test]
    fn an_unreadable_certificate_reports_the_path() {
        let e = certificate_fingerprint("/nonexistent/x.pem")
            .unwrap_err()
            .to_string();
        assert!(e.contains("/nonexistent/x.pem"), "{e}");
    }
}
