// License validation, storage and reading for the Base/Premium editions.
//
// Architecture:
// - License key = "SR-BASE-<JWT>" or "SR-PREMIUM-<JWT>", JWT signed Ed25519 by the server
// - Activation token = separate JWT, also signed by the server, binds the license to a machine
//   (machine_id claim must match the local machine_id). Without it, a copied license.key would
//   work on any machine. Activation tokens are issued by the server in a separate flow (Issue #49).
// - Both files live in app_data_dir/ — license.key and activation.token
// - get_edition() returns "free" unless BOTH license JWT is valid (signature + exp) AND
//   either there is no activation token (graceful pre-activation state) OR the activation token
//   matches the local machine_id.
//
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.

use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::Manager;

use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};

// Ed25519 public key for license verification.
//
// Production key generated 2026-04-25 alongside the maximus-api scaffold.
// The matching private key lives ONLY on the license server as env var
// ED25519_PRIVATE_KEY_PEM (see maximus-api/.env on Coolify).
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAmUTcl7xjt01uc2FhPgvP0at0I/Pie0JLh73AApNy+o8=\n\
-----END PUBLIC KEY-----\n";

const LICENSE_FILE: &str = "license.key";
const ACTIVATION_FILE: &str = "activation.token";

const KEY_PREFIX_BASE: &str = "SR-BASE-";
const KEY_PREFIX_PREMIUM: &str = "SR-PREMIUM-";

/// Decoded license metadata exposed to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseInfo {
    pub edition: String,
    pub email: String,
    pub features: Vec<String>,
    pub machine_limit: u32,
    pub issued_at: i64,
    pub expires_at: i64,
}

/// Claims embedded in the license JWT (signed by the license server).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LicenseClaims {
    sub: String, // email
    iss: String,
    iat: i64,
    exp: i64, // mandatory — see CWE-613
    edition: String,
    #[serde(default)]
    features: Vec<String>,
    machine_limit: u32,
}

/// Claims embedded in the activation token JWT (server-signed, machine-bound).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ActivationClaims {
    sub: String, // license id or hash
    iat: i64,
    exp: i64,
    machine_id: String,
}

fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
    app.path()
        .app_data_dir()
        .map_err(|e| format!("Cannot get app data dir: {}", e))
}

fn license_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
    Ok(app_data_dir(app)?.join(LICENSE_FILE))
}

fn activation_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
    Ok(app_data_dir(app)?.join(ACTIVATION_FILE))
}

/// Strip the human-readable prefix and return the bare JWT.
fn strip_prefix(key: &str) -> Result<&str, String> {
    let trimmed = key.trim();
    if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_BASE) {
        return Ok(jwt);
    }
    if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_PREMIUM) {
        return Ok(jwt);
    }
    Err("License key must start with SR-BASE- or SR-PREMIUM-".to_string())
}

/// Build a `Validation` with `exp` and `iat` mandatory. Assertions are explicit so a future
/// config change cannot silently disable expiry checking (CWE-613).
fn strict_validation() -> Validation {
    let mut validation = Validation::new(Algorithm::EdDSA);
    validation.validate_exp = true;
    validation.leeway = 0;
    validation.set_required_spec_claims(&["exp", "iat"]);
    validation
}

/// Build the production `DecodingKey` from the embedded PEM constant.
fn embedded_decoding_key() -> Result<DecodingKey, String> {
    DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes())
        .map_err(|e| format!("Invalid public key: {}", e))
}

/// Pure validation: decode the JWT, verify signature with the provided key, ensure the
/// edition claim is one we recognize. Returns `LicenseInfo` on success.
///
/// Separated from the Tauri command so tests can pass their own key.
fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result<LicenseInfo, String> {
    let jwt = strip_prefix(key)?;
    let validation = strict_validation();

    let data = decode::<LicenseClaims>(jwt, decoding_key, &validation)
        .map_err(|e| format!("Invalid license: {}", e))?;

    let claims = data.claims;
    if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM {
        return Err(format!("Unknown edition '{}'", claims.edition));
    }

    Ok(LicenseInfo {
        edition: claims.edition,
        email: claims.sub,
        features: claims.features,
        machine_limit: claims.machine_limit,
        issued_at: claims.iat,
        expires_at: claims.exp,
    })
}

/// Validate an activation token against the local machine. The token must be signed by the
/// license server and its `machine_id` claim must match the local machine identifier.
fn validate_activation_with_key(
    token: &str,
    local_machine_id: &str,
    decoding_key: &DecodingKey,
) -> Result<(), String> {
    let validation = strict_validation();

    let data = decode::<ActivationClaims>(token.trim(), decoding_key, &validation)
        .map_err(|e| format!("Invalid activation token: {}", e))?;

    if data.claims.machine_id != local_machine_id {
        return Err("Activation token belongs to a different machine".to_string());
    }
    Ok(())
}

// === Tauri commands ===========================================================================

/// Validate a license key without persisting it. Used by the UI to give immediate feedback
/// before the user confirms storage.
#[tauri::command]
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
    let decoding_key = embedded_decoding_key()?;
    validate_with_key(&key, &decoding_key)
}

/// Persist a previously-validated license key to disk. The activation token (machine binding)
/// is stored separately by [`store_activation_token`] once the server has issued one.
#[tauri::command]
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
    let decoding_key = embedded_decoding_key()?;
    let info = validate_with_key(&key, &decoding_key)?;
    let path = license_path(&app)?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
    }
    fs::write(&path, key.trim()).map_err(|e| format!("Cannot write license file: {}", e))?;
    Ok(info)
}

/// Persist a server-issued activation token (machine binding). The token is opaque to the
/// caller — it must validate against the local machine_id to be considered active.
#[tauri::command]
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
    let local_id = machine_id_internal()?;
    let decoding_key = embedded_decoding_key()?;
    validate_activation_with_key(&token, &local_id, &decoding_key)?;
    let path = activation_path(&app)?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
    }
    fs::write(&path, token.trim()).map_err(|e| format!("Cannot write activation file: {}", e))
}

/// Read the stored license without revalidating. Returns `None` when no license is present.
/// The returned info is only structurally decoded — call [`get_edition`] for the gating value.
#[tauri::command]
pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String> {
    let path = license_path(&app)?;
    if !path.exists() {
        return Ok(None);
    }
    let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?;
    let Ok(decoding_key) = embedded_decoding_key() else {
        return Ok(None);
    };
    Ok(validate_with_key(&key, &decoding_key).ok())
}

/// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates.
///
/// Returns "free" when:
/// - no license is stored,
/// - the license JWT is invalid or expired,
/// - an activation token exists but does not match this machine.
///
/// Note: a missing activation token is treated as a graceful pre-activation state and does
/// NOT downgrade the edition. Server-side activation happens later (Issue #53).
#[tauri::command]
pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
    Ok(current_edition(&app))
}

/// Dev-only edition override, compiled in ONLY under the `dev-override` Cargo
/// feature (off by default and absent from any release feature set). Gating on
/// `debug_assertions` instead would be CWE-489: a custom release build could
/// flip it on and the env var would become a Premium backdoor. When compiled
/// in, `SR_DEV_EDITION` forces the resolved edition (free|base|premium) so the
/// three tiers can be tested without real licenses; unrecognized values are
/// ignored and resolution falls through to the normal path.
fn dev_override_edition() -> Option<String> {
    #[cfg(feature = "dev-override")]
    {
        if let Ok(edition) = std::env::var("SR_DEV_EDITION") {
            if edition == EDITION_FREE || edition == EDITION_BASE || edition == EDITION_PREMIUM {
                return Some(edition);
            }
        }
    }
    None
}

/// Pure resolution of `(edition, signed features)` from license material.
///
/// Single choke point for the machine-binding rule: every downgrade path
/// returns `("free", [])`, so the signed `features[]` of a copied license can
/// never be honored once the edition is downgraded (CWE-863). Separated from
/// the fs/AppHandle plumbing so tests can exercise it with in-memory keys.
fn resolve_license_entitlements(
    license_key: &str,
    activation_token: Option<&str>,
    local_machine_id: &str,
    decoding_key: &DecodingKey,
) -> (String, Vec<String>) {
    let Ok(info) = validate_with_key(license_key, decoding_key) else {
        return (EDITION_FREE.to_string(), Vec::new());
    };
    // If an activation token exists, it must match the local machine. A missing
    // token is accepted (graceful pre-activation state).
    if let Some(token) = activation_token {
        if validate_activation_with_key(token, local_machine_id, decoding_key).is_err() {
            return (EDITION_FREE.to_string(), Vec::new());
        }
    }
    (info.edition, info.features)
}

/// Internal helper used by `entitlements::check_entitlement`. Resolves the
/// effective edition AND the signed per-license feature overrides through the
/// SAME machine-binding path. Never returns an error — any failure resolves to
/// `("free", [])` so feature gates fail closed, features included (CWE-863: a
/// copied `license.key` must not keep its signed `features[]` once downgraded).
///
/// Priority: dev override (dev-override builds only) > Premium (via Compte
/// Maximus with active subscription) > Base (offline license) > Free.
pub(crate) fn current_entitlements(app: &tauri::AppHandle) -> (String, Vec<String>) {
    // Dev-only tier testing — compiled out of normal builds (see the helper).
    if let Some(edition) = dev_override_edition() {
        return (edition, Vec::new());
    }

    // Check Compte Maximus subscription first — Premium overrides Base. This
    // path never reads the license JWT, so it carries no signed features.
    if let Some(edition) = check_account_edition(app) {
        if edition == EDITION_PREMIUM {
            return (edition, Vec::new());
        }
    }

    let free = || (EDITION_FREE.to_string(), Vec::new());

    let Ok(path) = license_path(app) else {
        return free();
    };
    if !path.exists() {
        return free();
    }
    let Ok(key) = fs::read_to_string(&path) else {
        return free();
    };
    let Ok(decoding_key) = embedded_decoding_key() else {
        return free();
    };

    // Read the activation token when present. An unreadable token or machine
    // id resolves to free, matching the strict posture of `current_edition`
    // before this refactor.
    let mut activation_token: Option<String> = None;
    let mut local_machine_id = String::new();
    if let Ok(act_path) = activation_path(app) {
        if act_path.exists() {
            let Ok(token) = fs::read_to_string(&act_path) else {
                return free();
            };
            let Ok(local_id) = machine_id_internal() else {
                return free();
            };
            activation_token = Some(token);
            local_machine_id = local_id;
        }
    }

    resolve_license_entitlements(
        &key,
        activation_token.as_deref(),
        &local_machine_id,
        &decoding_key,
    )
}

/// Edition-only view of [`current_entitlements`], used by `get_edition` and any
/// caller that does not need the feature overrides.
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
    current_entitlements(app).0
}

/// Read the HMAC-verified account cache to check for an active Premium
/// subscription. Legacy unsigned caches (from v0.7.x) and tampered
/// payloads return None — Premium features stay locked until the user
/// re-authenticates or the next token refresh re-signs the cache.
///
/// This is intentional: before HMAC signing, any local process could
/// write `{"subscription_status": "active"}` to account.json and
/// bypass the paywall. Fail-closed is the correct posture here.
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
    let account = super::account_cache::load_verified(app).ok().flatten()?;
    match account.subscription_status.as_deref() {
        Some("active") => Some(EDITION_PREMIUM.to_string()),
        _ => None,
    }
}

/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
/// or hardware migration, in which case the user must re-activate (handled in Issue #53).
#[tauri::command]
pub fn get_machine_id() -> Result<String, String> {
    machine_id_internal()
}

fn machine_id_internal() -> Result<String, String> {
    machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
}

// License server API base URL. Overridable via MAXIMUS_API_URL env var for development.
fn api_base_url() -> String {
    std::env::var("MAXIMUS_API_URL")
        .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}

/// Machine info returned by the license server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineInfo {
    pub machine_id: String,
    pub machine_name: Option<String>,
    pub activated_at: String,
    pub last_seen_at: String,
}

/// Activation status for display in the UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivationStatus {
    pub is_activated: bool,
    pub machine_id: String,
}

/// Activate this machine with the license server. Reads the stored license key, sends
/// the machine_id to the API, and stores the returned activation token.
#[tauri::command]
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
    let key_path = license_path(&app)?;
    if !key_path.exists() {
        return Err("No license key stored".to_string());
    }
    let license_key =
        fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
    let machine_id = machine_id_internal()?;
    let machine_name = hostname::get()
        .ok()
        .and_then(|h| h.into_string().ok());

    let url = format!("{}/licenses/activate", api_base_url());
    let client = reqwest::Client::new();

    let mut body = serde_json::json!({
        "license_key": license_key.trim(),
        "machine_id": machine_id,
    });
    if let Some(name) = machine_name {
        body["machine_name"] = serde_json::Value::String(name);
    }

    let resp = client
        .post(&url)
        .json(&body)
        .timeout(std::time::Duration::from_secs(15))
        .send()
        .await
        .map_err(|e| format!("Cannot reach license server: {}", e))?;

    let status = resp.status();
    let resp_body: serde_json::Value = resp
        .json()
        .await
        .map_err(|e| format!("Invalid server response: {}", e))?;

    if !status.is_success() {
        let error = resp_body["error"]
            .as_str()
            .unwrap_or("Activation failed");
        return Err(error.to_string());
    }

    let token = resp_body["activation_token"]
        .as_str()
        .ok_or("Server did not return an activation token")?;

    // store_activation_token validates the token against local machine_id before writing
    store_activation_token(app, token.to_string())?;
    Ok(())
}

/// Deactivate a machine on the license server, freeing a slot.
#[tauri::command]
pub async fn deactivate_machine(
    app: tauri::AppHandle,
    machine_id: String,
) -> Result<(), String> {
    let key_path = license_path(&app)?;
    if !key_path.exists() {
        return Err("No license key stored".to_string());
    }
    let license_key =
        fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;

    let url = format!("{}/licenses/deactivate", api_base_url());
    let client = reqwest::Client::new();
    let resp = client
        .post(&url)
        .json(&serde_json::json!({
            "license_key": license_key.trim(),
            "machine_id": machine_id,
        }))
        .timeout(std::time::Duration::from_secs(15))
        .send()
        .await
        .map_err(|e| format!("Cannot reach license server: {}", e))?;

    let status = resp.status();
    if !status.is_success() {
        let resp_body: serde_json::Value = resp
            .json()
            .await
            .unwrap_or(serde_json::json!({"error": "Deactivation failed"}));
        let error = resp_body["error"].as_str().unwrap_or("Deactivation failed");
        return Err(error.to_string());
    }

    // If deactivating this machine, remove the local activation token
    let local_id = machine_id_internal()?;
    if machine_id == local_id {
        let act_path = activation_path(&app)?;
        if act_path.exists() {
            let _ = fs::remove_file(&act_path);
        }
    }

    Ok(())
}

/// List all machines currently activated for the stored license.
#[tauri::command]
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, String> {
    let key_path = license_path(&app)?;
    if !key_path.exists() {
        return Ok(vec![]);
    }
    let license_key =
        fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;

    let url = format!("{}/licenses/verify", api_base_url());
    let client = reqwest::Client::new();
    let resp = client
        .post(&url)
        .json(&serde_json::json!({
            "license_key": license_key.trim(),
        }))
        .timeout(std::time::Duration::from_secs(15))
        .send()
        .await
        .map_err(|e| format!("Cannot reach license server: {}", e))?;

    if !resp.status().is_success() {
        return Err("Cannot verify license".to_string());
    }

    let body: serde_json::Value = resp
        .json()
        .await
        .map_err(|e| format!("Invalid server response: {}", e))?;

    // The verify endpoint returns machines in the response when valid
    let machines = body["machines"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
                .collect()
        })
        .unwrap_or_default();

    Ok(machines)
}

/// Check the local activation status without contacting the server.
#[tauri::command]
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
    let machine_id = machine_id_internal()?;
    let act_path = activation_path(&app)?;

    let is_activated = if act_path.exists() {
        if let Ok(token) = fs::read_to_string(&act_path) {
            if let Ok(decoding_key) = embedded_decoding_key() {
                validate_activation_with_key(&token, &machine_id, &decoding_key).is_ok()
            } else {
                false
            }
        } else {
            false
        }
    } else {
        false
    };

    Ok(ActivationStatus {
        is_activated,
        machine_id,
    })
}

// === Tests ====================================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use ed25519_dalek::SigningKey;
    use jsonwebtoken::{encode, EncodingKey, Header};

    // === Manual DER encoder for the Ed25519 private key =======================================
    // We avoid the `pem` feature on `ed25519-dalek` because the `LineEnding` re-export path
    // varies across `pkcs8`/`spki`/`der` versions. The Ed25519 PKCS#8 v1 byte layout is fixed
    // and trivial: 16-byte prefix + 32-byte raw seed.
    //
    // Note the asymmetry in jsonwebtoken's API:
    // - `EncodingKey::from_ed_der` expects a PKCS#8-wrapped private key (passed to ring's
    //   `Ed25519KeyPair::from_pkcs8`).
    // - `DecodingKey::from_ed_der` expects the *raw* 32-byte public key (passed to ring's
    //   `UnparsedPublicKey::new` which takes raw bytes, not a SubjectPublicKeyInfo).

    /// Wrap a 32-byte Ed25519 seed in a PKCS#8 v1 PrivateKeyInfo DER blob.
    fn ed25519_pkcs8_private_der(seed: &[u8; 32]) -> Vec<u8> {
        // SEQUENCE(46) {
        //   INTEGER(1) 0           // version v1
        //   SEQUENCE(5) {
        //     OID(3) 1.3.101.112   // Ed25519
        //   }
        //   OCTET STRING(34) {
        //     OCTET STRING(32) <32 bytes>
        //   }
        // }
        let mut der = vec![
            0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
            0x04, 0x20,
        ];
        der.extend_from_slice(seed);
        der
    }

    /// Build a deterministic test keypair so signed tokens are reproducible across runs.
    fn test_keys(seed: [u8; 32]) -> (EncodingKey, DecodingKey) {
        let signing_key = SigningKey::from_bytes(&seed);
        let pubkey_bytes = signing_key.verifying_key().to_bytes();
        let priv_der = ed25519_pkcs8_private_der(&seed);
        let encoding_key = EncodingKey::from_ed_der(&priv_der);
        // Raw 32-byte public key (NOT SubjectPublicKeyInfo) — see note above.
        let decoding_key = DecodingKey::from_ed_der(&pubkey_bytes);
        (encoding_key, decoding_key)
    }

    fn default_keys() -> (EncodingKey, DecodingKey) {
        test_keys([42u8; 32])
    }

    fn make_token<T: serde::Serialize>(encoding_key: &EncodingKey, claims: &T) -> String {
        encode(&Header::new(Algorithm::EdDSA), claims, encoding_key).unwrap()
    }

    fn now() -> i64 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64
    }

    #[test]
    fn rejects_key_without_prefix() {
        let (_enc, dec) = default_keys();
        let result = validate_with_key("nonsense", &dec);
        assert!(result.is_err());
    }

    #[test]
    fn accepts_well_formed_base_license() {
        let (enc, dec) = default_keys();
        let claims = LicenseClaims {
            sub: "user@example.com".to_string(),
            iss: "lacompagniemaximus.com".to_string(),
            iat: now(),
            exp: now() + 86400,
            edition: EDITION_BASE.to_string(),
            features: vec!["auto-update".to_string()],
            machine_limit: 3,
        };
        let jwt = make_token(&enc, &claims);
        let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
        let info = validate_with_key(&key, &dec).unwrap();
        assert_eq!(info.edition, EDITION_BASE);
        assert_eq!(info.email, "user@example.com");
        assert_eq!(info.machine_limit, 3);
    }

    #[test]
    fn rejects_expired_license() {
        let (enc, dec) = default_keys();
        let claims = LicenseClaims {
            sub: "user@example.com".to_string(),
            iss: "lacompagniemaximus.com".to_string(),
            iat: now() - 1000,
            exp: now() - 100,
            edition: EDITION_BASE.to_string(),
            features: vec![],
            machine_limit: 3,
        };
        let jwt = make_token(&enc, &claims);
        let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
        let result = validate_with_key(&key, &dec);
        assert!(result.is_err(), "expired license must be rejected");
    }

    #[test]
    fn rejects_license_signed_with_wrong_key() {
        let (enc_signer, _dec_signer) = default_keys();
        let (_enc_other, dec_other) = test_keys([7u8; 32]);
        let claims = LicenseClaims {
            sub: "user@example.com".to_string(),
            iss: "lacompagniemaximus.com".to_string(),
            iat: now(),
            exp: now() + 86400,
            edition: EDITION_BASE.to_string(),
            features: vec![],
            machine_limit: 3,
        };
        let jwt = make_token(&enc_signer, &claims);
        let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
        let result = validate_with_key(&key, &dec_other);
        assert!(result.is_err(), "wrong-key signature must be rejected");
    }

    #[test]
    fn rejects_corrupted_jwt() {
        let (_enc, dec) = default_keys();
        let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
        let result = validate_with_key(&key, &dec);
        assert!(result.is_err());
    }

    #[test]
    fn rejects_unknown_edition() {
        let (enc, dec) = default_keys();
        let claims = LicenseClaims {
            sub: "user@example.com".to_string(),
            iss: "lacompagniemaximus.com".to_string(),
            iat: now(),
            exp: now() + 86400,
            edition: "enterprise".to_string(),
            features: vec![],
            machine_limit: 3,
        };
        let jwt = make_token(&enc, &claims);
        let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
        let result = validate_with_key(&key, &dec);
        assert!(result.is_err());
    }

    #[test]
    fn activation_token_matches_machine() {
        let (enc, dec) = default_keys();
        let claims = ActivationClaims {
            sub: "license-id".to_string(),
            iat: now(),
            exp: now() + 86400,
            machine_id: "this-machine".to_string(),
        };
        let token = make_token(&enc, &claims);
        assert!(validate_activation_with_key(&token, "this-machine", &dec).is_ok());
    }

    #[test]
    fn activation_token_rejects_other_machine() {
        let (enc, dec) = default_keys();
        let claims = ActivationClaims {
            sub: "license-id".to_string(),
            iat: now(),
            exp: now() + 86400,
            machine_id: "machine-A".to_string(),
        };
        let token = make_token(&enc, &claims);
        let result = validate_activation_with_key(&token, "machine-B", &dec);
        assert!(result.is_err(), "copied activation token must be rejected");
    }

    #[test]
    fn embedded_public_key_pem_parses() {
        // Sanity check that the production PEM constant is well-formed.
        assert!(embedded_decoding_key().is_ok());
    }

    // === Entitlements resolution (edition + signed features, machine binding) =================

    fn base_license_with_features(enc: &EncodingKey, features: Vec<String>) -> String {
        let claims = LicenseClaims {
            sub: "user@example.com".to_string(),
            iss: "lacompagniemaximus.com".to_string(),
            iat: now(),
            exp: now() + 86400,
            edition: EDITION_BASE.to_string(),
            features,
            machine_limit: 3,
        };
        let jwt = make_token(enc, &claims);
        format!("{}{}", KEY_PREFIX_BASE, jwt)
    }

    fn activation_for(enc: &EncodingKey, machine_id: &str) -> String {
        let claims = ActivationClaims {
            sub: "license-id".to_string(),
            iat: now(),
            exp: now() + 86400,
            machine_id: machine_id.to_string(),
        };
        make_token(enc, &claims)
    }

    #[test]
    fn machine_match_keeps_edition_and_features() {
        let (enc, dec) = default_keys();
        let key = base_license_with_features(&enc, vec!["balance".to_string()]);
        let token = activation_for(&enc, "machine-A");
        let (edition, features) =
            resolve_license_entitlements(&key, Some(&token), "machine-A", &dec);
        assert_eq!(edition, EDITION_BASE);
        assert_eq!(features, vec!["balance".to_string()]);
    }

    #[test]
    fn machine_mismatch_downgrades_to_free_and_drops_features() {
        // CWE-863: a copied license.key + activation.token still carries its
        // signed features[] — they must vanish together with the downgrade.
        let (enc, dec) = default_keys();
        let key = base_license_with_features(&enc, vec!["balance".to_string()]);
        let token = activation_for(&enc, "machine-A");
        let (edition, features) =
            resolve_license_entitlements(&key, Some(&token), "machine-B", &dec);
        assert_eq!(edition, EDITION_FREE);
        assert!(
            features.is_empty(),
            "signed features must not survive the machine-binding downgrade"
        );
    }

    #[test]
    fn missing_activation_token_keeps_edition_and_features() {
        // Graceful pre-activation state: no token yet, the license still counts.
        let (enc, dec) = default_keys();
        let key = base_license_with_features(&enc, vec!["balance".to_string()]);
        let (edition, features) = resolve_license_entitlements(&key, None, "machine-A", &dec);
        assert_eq!(edition, EDITION_BASE);
        assert_eq!(features, vec!["balance".to_string()]);
    }

    #[test]
    fn invalid_license_resolves_free_without_features() {
        let (_enc, dec) = default_keys();
        let (edition, features) =
            resolve_license_entitlements("SR-BASE-not.a.jwt", None, "machine-A", &dec);
        assert_eq!(edition, EDITION_FREE);
        assert!(features.is_empty());
    }

    // === Dev override =========================================================================

    /// The dev override must be dead code in normal builds: even with
    /// SR_DEV_EDITION set, nothing reads it when the `dev-override` Cargo
    /// feature is off (CWE-489 — a release binary must never honor the env
    /// var). Env-var note: under this feature-off build NO code path reads
    /// SR_DEV_EDITION, so setting it here cannot race with parallel tests.
    #[cfg(not(feature = "dev-override"))]
    #[test]
    fn sr_dev_edition_has_no_effect_when_feature_off() {
        std::env::set_var("SR_DEV_EDITION", "premium");
        assert_eq!(dev_override_edition(), None);
        std::env::remove_var("SR_DEV_EDITION");
    }

    // Companion coverage for `cargo test --features dev-override` (not part of
    // the normal CI run). SR_DEV_EDITION is process-global and cargo test runs
    // tests on parallel threads, so every env manipulation serializes on a lock.
    #[cfg(feature = "dev-override")]
    mod dev_override_on {
        use super::super::*;
        use std::sync::Mutex;

        static ENV_LOCK: Mutex<()> = Mutex::new(());

        #[test]
        fn sr_dev_edition_forces_edition() {
            let _guard = ENV_LOCK.lock().unwrap();
            std::env::set_var("SR_DEV_EDITION", "premium");
            assert_eq!(dev_override_edition(), Some("premium".to_string()));
            std::env::remove_var("SR_DEV_EDITION");
        }

        #[test]
        fn sr_dev_edition_unknown_value_ignored() {
            let _guard = ENV_LOCK.lock().unwrap();
            std::env::set_var("SR_DEV_EDITION", "enterprise");
            assert_eq!(dev_override_edition(), None);
            std::env::remove_var("SR_DEV_EDITION");
        }
    }
}
