//! Client/server version compatibility.
//!
//! Every component of a release — the `cambium` CLI, the desktop app, and the
//! Python server — ships the same `major.minor.patch` string. Compatibility is
//! decided on the MAJOR component alone: a 0.x client talks to any 0.x server
//! and refuses a 1.x one outright, and vice versa. Minor/patch skew inside one
//! major line stays allowed, so a server can ship a bugfix without forcing
//! every user to upgrade in lockstep.
//!
//! Both ends enforce this, because either end may be the stale one. The client
//! compares the version `/api/server` advertises before it sends credentials;
//! the server compares the `X-Cambium-Client` header that every request
//! carries and answers a mismatch with 426 Upgrade Required. A request with no
//! such header is a client from before this handshake existed, and is refused
//! on the same grounds.

/// This client's version. Taken from cambium-core's own package version so a
/// release bumps exactly one number per component and they cannot drift.
pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");

/// Header the client stamps on every request so the server can apply the same
/// rule from its side. Mirrored in `client.js` for the webview's request path.
pub const CLIENT_VERSION_HEADER: &str = "X-Cambium-Client";

/// The MAJOR component of a `major.minor.patch` string, or `None` when it
/// doesn't parse as one. Callers treat unparseable as incompatible rather
/// than assuming it's current — a version we can't read is one we can't
/// vouch for.
pub fn major_of(version: &str) -> Option<u64> {
    version.trim().split('.').next()?.parse().ok()
}

/// True when a client and a server on these versions may talk to each other.
pub fn is_compatible(client_version: &str, server_version: &str) -> bool {
    match (major_of(client_version), major_of(server_version)) {
        (Some(client), Some(server)) => client == server,
        _ => false,
    }
}

/// The user-facing explanation for a refused connection. Names which side is
/// behind, because "incompatible" alone leaves the user with nothing to do.
pub fn incompatibility_message(server_version: &str) -> String {
    let client = CLIENT_VERSION;
    match (major_of(client), major_of(server_version)) {
        (Some(c), Some(s)) if s > c => format!(
            "This server runs Cambium {server_version}, which is newer than this \
             client ({client}). Update the client to connect."
        ),
        (Some(c), Some(s)) if s < c => format!(
            "This server runs Cambium {server_version}, which is older than this \
             client ({client}). The server needs to be updated to accept this client."
        ),
        _ => format!(
            "This server reported an unrecognized version ({server_version}); \
             this client is {client}."
        ),
    }
}

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

    #[test]
    fn major_parses_and_rejects_garbage() {
        assert_eq!(major_of("0.2.0"), Some(0));
        assert_eq!(major_of("1.0.0"), Some(1));
        assert_eq!(major_of("12.3.4"), Some(12));
        assert_eq!(major_of(" 2.0.0 "), Some(2));
        assert_eq!(major_of(""), None);
        assert_eq!(major_of("nightly"), None);
        assert_eq!(major_of("v1.0.0"), None);
    }

    #[test]
    fn same_major_is_compatible_across_minor_and_patch() {
        assert!(is_compatible("0.2.0", "0.2.0"));
        assert!(is_compatible("0.2.0", "0.9.9"));
        assert!(is_compatible("0.2.0", "0.1.0"));
        assert!(is_compatible("1.4.0", "1.0.0"));
    }

    #[test]
    fn different_major_is_refused_in_both_directions() {
        assert!(!is_compatible("0.2.0", "1.0.0"));
        assert!(!is_compatible("1.0.0", "0.2.0"));
        assert!(!is_compatible("1.0.0", "2.0.0"));
    }

    #[test]
    fn unreadable_version_is_refused_not_assumed_current() {
        assert!(!is_compatible("0.2.0", ""));
        assert!(!is_compatible("0.2.0", "unknown"));
        assert!(!is_compatible("", "0.2.0"));
    }

    #[test]
    fn shipped_client_version_is_parseable() {
        // Guards against a release bumping Cargo.toml to something this
        // module can't read, which would refuse every server.
        assert!(major_of(CLIENT_VERSION).is_some(), "CLIENT_VERSION={CLIENT_VERSION}");
    }

    #[test]
    fn message_names_the_side_that_is_behind() {
        let newer = incompatibility_message("99.0.0");
        assert!(newer.contains("newer"), "{newer}");
        assert!(newer.contains("Update the client"), "{newer}");

        // CLIENT_VERSION is 0.x today, so an older server needs a major
        // below it; skip that arm rather than assert something false.
        if major_of(CLIENT_VERSION).unwrap() > 0 {
            let older = incompatibility_message("0.1.0");
            assert!(older.contains("older"), "{older}");
        }

        let junk = incompatibility_message("banana");
        assert!(junk.contains("unrecognized"), "{junk}");
    }
}
