//! STAGING-ONLY seller-facing SOLD-row experiment.
//!
//! Static RE has exhausted CardsDLL on one question: for a `closed` row,
//! `IS_GLOW = (bidState != none)` and `INBOX = (bidState in {highest, buyNow})`,
//! so `closed/highest` and `closed/buyNow` are **bit-identical** to every native
//! consumer. But `bidState` is also published to the movie verbatim as `YOURBID`,
//! so the FUT ActionScript front end CAN separate them. This module exists to ask
//! the client which one it treats as the seller's sale, by holding every other
//! field constant and changing exactly that token.
//!
//! # Production safety
//!
//! Every knob is OFF unless its environment variable is set explicitly, and
//! [`SoldExperiment::enabled`] gates every projection at the call site. With no
//! env set this module changes nothing: `/tradePile` and `/trade/status` emit only
//! real active auctions (the Fix A invariant) and `/tradePile/counts` reports
//! `sold: 0` exactly as production does today. An unrecognised value is treated as
//! OFF rather than as a default token, because silently picking a token would
//! fabricate the very answer the experiment is meant to measure.
//!
//! # Why this cannot be "discovery"
//!
//! Our server IS the server, so nothing here recovers EA's original contract. It
//! is a controlled discriminator: the client's *reaction* (which bucket it draws
//! the row in, what it counts, and which request it issues to clear it) is the
//! observation.

/// What `/tradePile/counts.count` should report while a sold row exists. FIFA 17's
/// exact semantics for `count` are unknown — it is either the number of live
/// auctions or the whole Transfer List membership — so it is a controlled variable
/// rather than a guess.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CountMode {
    /// `count` = active auctions only (current production behaviour).
    Active,
    /// `count` = active + uncleared sold (Transfer List membership).
    ActivePlusSold,
}

/// Resolved experiment configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SoldExperiment {
    /// The `bidState` token to emit on a sold seller row. `None` disables every
    /// part of the experiment.
    pub bid_state: Option<&'static str>,
    /// The `coinsProcessed` value to emit (published to Flash as `COINS_AWARDED`).
    pub coins_processed: i64,
    pub count_mode: CountMode,
}

impl SoldExperiment {
    /// All-off. This is what production runs.
    pub const OFF: Self = Self {
        bid_state: None,
        coins_processed: 0,
        count_mode: CountMode::Active,
    };

    /// Read the configuration from the environment.
    ///
    /// * `OPENFUT_FIFA17_SOLD_EXPERIMENT` — `highest` | `buyNow`; anything else
    ///   (including absent) is OFF.
    /// * `OPENFUT_FIFA17_SOLD_COINS_PROCESSED` — `1` to emit 1, else 0.
    /// * `OPENFUT_FIFA17_SOLD_COUNT_MODE` — `active_plus_sold`, else `active`.
    pub fn from_env() -> Self {
        Self::from_values(
            std::env::var("OPENFUT_FIFA17_SOLD_EXPERIMENT")
                .ok()
                .as_deref(),
            std::env::var("OPENFUT_FIFA17_SOLD_COINS_PROCESSED")
                .ok()
                .as_deref(),
            std::env::var("OPENFUT_FIFA17_SOLD_COUNT_MODE")
                .ok()
                .as_deref(),
        )
    }

    /// Pure resolver, so the parsing rules are testable without touching the
    /// process environment.
    pub fn from_values(
        experiment: Option<&str>,
        coins_processed: Option<&str>,
        count_mode: Option<&str>,
    ) -> Self {
        // Matched case-insensitively for operator convenience, but ONLY the two
        // real FIFA 17 tokens are accepted. `none`/`outbid` are deliberately not
        // offered: neither can describe a completed sale, and `none` on a closed
        // row clears IS_GLOW, which would test nothing.
        let bid_state = match experiment.map(str::trim).unwrap_or("") {
            s if s.eq_ignore_ascii_case("highest") => Some("highest"),
            s if s.eq_ignore_ascii_case("buynow") => Some("buyNow"),
            _ => None,
        };
        Self {
            bid_state,
            coins_processed: i64::from(coins_processed == Some("1")),
            count_mode: match count_mode.map(str::trim).unwrap_or("") {
                s if s.eq_ignore_ascii_case("active_plus_sold") => CountMode::ActivePlusSold,
                _ => CountMode::Active,
            },
        }
    }

    /// Whether any sold projection is active. Production: always false.
    pub fn enabled(&self) -> bool {
        self.bid_state.is_some()
    }

    /// A one-line banner for the host's startup log, so a staging run can never be
    /// mistaken for a production one in a capture.
    pub fn banner(&self) -> String {
        match self.bid_state {
            None => "sold-experiment=OFF (production behaviour)".to_string(),
            Some(b) => format!(
                "sold-experiment=ON bidState={b} coinsProcessed={} countMode={:?} \
                 -- STAGING ONLY, never production",
                self.coins_processed, self.count_mode
            ),
        }
    }
}

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

    #[test]
    fn absent_env_is_off_and_matches_production() {
        let e = SoldExperiment::from_values(None, None, None);
        assert!(!e.enabled());
        assert_eq!(e, SoldExperiment::OFF);
        assert_eq!(e.coins_processed, 0);
        assert_eq!(e.count_mode, CountMode::Active);
    }

    /// The whole point of the harness: exactly two tokens, and nothing else may
    /// turn it on. A typo must not silently select a token and manufacture the
    /// answer we are trying to measure.
    #[test]
    fn only_the_two_real_tokens_enable_it() {
        for (input, expected) in [
            ("highest", Some("highest")),
            ("HIGHEST", Some("highest")),
            ("buyNow", Some("buyNow")),
            ("buynow", Some("buyNow")),
            (" highest ", Some("highest")),
            ("off", None),
            ("none", None),
            ("outbid", None),
            ("closed", None),
            ("", None),
            ("hihgest", None), // typo
            ("1", None),
        ] {
            let e = SoldExperiment::from_values(Some(input), None, None);
            assert_eq!(e.bid_state, expected, "input {input:?}");
        }
    }

    #[test]
    fn coins_processed_is_strictly_one_or_zero() {
        for (input, expected) in [
            (Some("1"), 1),
            (Some("0"), 0),
            (Some("true"), 0), // only "1" means 1 — no fuzzy truthiness
            (Some(""), 0),
            (None, 0),
        ] {
            assert_eq!(
                SoldExperiment::from_values(Some("highest"), input, None).coins_processed,
                expected,
                "input {input:?}"
            );
        }
    }

    #[test]
    fn count_mode_defaults_to_production_behaviour() {
        let mk = |m| SoldExperiment::from_values(Some("highest"), None, m).count_mode;
        assert_eq!(mk(None), CountMode::Active);
        assert_eq!(mk(Some("active")), CountMode::Active);
        assert_eq!(mk(Some("active_plus_sold")), CountMode::ActivePlusSold);
        assert_eq!(mk(Some("ACTIVE_PLUS_SOLD")), CountMode::ActivePlusSold);
        assert_eq!(mk(Some("everything")), CountMode::Active, "unknown -> safe");
    }

    #[test]
    fn banner_names_the_variant_under_test() {
        assert!(SoldExperiment::OFF.banner().contains("OFF"));
        let on = SoldExperiment::from_values(Some("buyNow"), Some("1"), None);
        let b = on.banner();
        assert!(b.contains("bidState=buyNow"), "{b}");
        assert!(b.contains("coinsProcessed=1"), "{b}");
        assert!(b.contains("STAGING ONLY"), "{b}");
    }
}
