//! # openfut-utas-host
//!
//! The first live FIFA 17 **UTAS migration host**. It fronts the client-visible
//! UTAS port and does route-level migration:
//!
//! ```text
//!   FIFA 17  ──HTTP──▶  openfut-utas-host
//!                         ├── GET …/club  ──▶ FIFA17 adapter ──▶ OpenFUT Core
//!                         └── everything else ──▶ Python UTAS oracle (verbatim)
//! ```
//!
//! ## Safety rules (see the mission brief)
//!
//! * **Classification happens once, before any execution** ([`classify`]). A
//!   request is either handled in Rust or proxied to Python — never both, and
//!   there is NO "try Rust then retry on Python", which could double-apply a
//!   mutation. `/club` is read-only, but the rule holds regardless.
//! * The Rust `/club` path NEVER contacts Python; the passthrough path NEVER
//!   runs Core logic.
//! * A Core failure on `/club` returns an empty (but valid) `{"itemData":[]}`
//!   and logs an error — it does NOT fall back to Python.
//!
//! ## Transport (worker D)
//!
//! UTAS is plaintext HTTP/1.1 keep-alive, no TLS. Body is read by `Content-Length`
//! before responding; responses carry `Content-Length` and `Content-Type:
//! application/json` only when a body is present.
//!
//! ## The asset-id boundary
//!
//! FIFA renders an owned card from a real FIFA asset id (`resourceId & 0xffffff`
//! resolved against the client's local DB). Core's synthetic catalogue has none,
//! so [`ItemIdentityResolver`] is injected and unresolved items are dropped, not
//! faked (see `club_response`). With today's empty mapping, `/club` returns
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.

pub mod account_store;
pub mod async_bridge;
pub mod clientdata_store;
pub mod config;
pub mod economy_store;
pub mod market;
pub mod market_store;
pub mod pile_store;
pub mod sold_experiment;

use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};

use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
use openfut_adapter_fifa17::fut::club_response::{
    shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
};
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField};
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
use openfut_adapter_fifa17::fut::match_wire;
use openfut_adapter_fifa17::fut::non_economy;
use openfut_adapter_fifa17::fut::owned_query::{
    is_special_rareflag, map_to_core, parse_club_query, MapError,
};
use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate;
use openfut_adapter_fifa17::fut::sbc as fifa17_sbc;
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver};
use openfut_adapter_fifa17::fut::squad_ext::{
    build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
};
use openfut_adapter_fifa17::fut::squad_projection::{
    project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
    SquadProjection, SquadProjectionInput,
};
use openfut_adapter_fifa17::fut::store_catalog::{
    build_purchasegroup, owned_pack_id_for_definition,
};
use openfut_adapter_fifa17::fut::store_session::{
    validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
};
use openfut_identity::ExternalIdentityStore;
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};

use account_store::{AccountStore, RenameOutcome};
use clientdata_store::ClientDataStore;
use config::{HostConfig, SbcPostCommitFault};

// ───────────────────────────── Route classification ─────────────────────────

/// The route decision, taken once, before execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Route {
    /// `GET …/club` — the owned-player search, served from Core.
    Club,
    /// `PUT …/club` or `PUT/POST …/user/club` — validates and persists the
    /// FIFA club name/abbreviation, then returns the zero-atom `{}` ack.
    ClubRename,
    /// `PUT …/squad/<n>` — full squad replacement, committed to Core.
    SquadReplace,
    /// `GET …/squad/list` — the squad summary, projected from Core.
    SquadList,
    /// `GET …/squad/active` — the active squad object, projected from Core.
    SquadActive,
    /// `GET …/userMassInfo` — served FULLY from Rust: the Core squad projection
    /// plus the Rust/Core economy (coins + unopened packs). No Python.
    UserMassInfo,
    /// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
    /// body (or the configured default), and opens a Rust session. Never proxied.
    Auth,
    /// `POST /openfut/account/sync` — launcher control-plane account summary,
    /// served from Rust with authoritative Core coins/entitlements. Never Python.
    AccountSync,
    /// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store
    /// (`userHubData` etc.), round-tripped through the Rust client-data store.
    ClientData,
    /// `POST /openfut/fifa17/capability` — launcher capability registration,
    /// owned entirely in Rust (no economy, no proxy).
    Capability,
    /// `GET …/store/purchasegroup…` — proxied to Python for the authoritative
    /// economy body, with the empty-My-Packs topology overlaid from the Rust
    /// session mode (the 65534 sentinel is stripped for a verified clean-v1 SID).
    StorePurchaseGroup,
    /// `GET …/user/accountinfo` — Rust-owned static `{}` (production oracle body).
    AccountInfo,
    /// `GET …/settings` — Rust-owned static `{"configs":[]}`.
    Settings,
    /// `GET …/leaderboards/options` — Rust-owned static `{}`.
    LeaderboardOptions,
    /// `PUT …/match/reset` — Rust-owned no-op ack `{}`.
    MatchReset,
    /// `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired
    /// FUT security-question service, owned in Rust as a stateless ack.
    SecurityQuestion,
    /// `GET …/club/stats/staff` — Rust-owned static `{}` (production oracle body;
    /// FIFA's staff-bonus stat set is deliberately empty).
    ClubStatsStaff,
    /// `GET …/hub` — the FUT hub tile counts (club players + auction/tradePile),
    /// derived from Core inventory + the durable market store (no Python).
    Hub,
    /// `GET …/club/stats/{year,consumables}` — the MY CLUB stat set, computed
    /// Core-accurately in Rust (player tiers, staff/consumable families, nation
    /// buckets). club/stats/staff stays a separate empty-set route.
    ClubStats,
    /// `GET …/store` (eligibility gate), `…/match/keepalive`, `…/captcha`,
    /// `…/tfa`, `…/livemessage`, `…/activeMessage` — Rust-owned UNCONDITIONAL
    /// static acks, byte-identical to the Python oracle's constant responses
    /// (these are not flag-gated in the oracle, so a constant is exact parity).
    StaticAck,
    /// `GET …/watchList` — the transfer watch list, served empty from Rust with
    /// authoritative Core credits (the oracle persists no watches; add/remove is a
    /// no-op ack). Body: `{auctionInfo:[], credits, total:0}`.
    WatchList,
    /// `GET …/user` — the FUT user profile `{"userInfo": …}`, the same userInfo
    /// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
    User,
    /// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and
    /// the club-identity read service are disabled in this emulator, so these
    /// reads return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
    /// `FUT_CLUB_IDENTITY` off. Club rename is a separate Rust-owned route.
    FeatureOffEmpty,
    /// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds
    /// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring
    /// the oracle's `defs_route`/`item_def`. The client renders the real card from
    /// its LOCAL DB, so a valid-shaped placeholder is exact parity.
    ItemDefs,
    /// `GET …/marketdata` and `…/marketdata/pricelimits` — suggested pricing.
    /// `/pricelimits` MUST be a bare ARRAY (one `{defId,minPrice,maxPrice}` per
    /// queried defId); plain `/marketdata` MUST be an OBJECT `{minPrice,maxPrice}`.
    /// Container type is load-bearing (object-where-array froze a live client);
    /// the handler picks it from the path. Constant band 150..15000.
    MarketData,
    /// Anything else — proxied verbatim to the Python oracle.
    Passthrough,
}

/// Classify a request ONCE, before execution. Rust owns complete route families;
/// there is no "try Rust then Python", so a mutation can never be double-applied.
/// Numeric `GET …/squad/<n>` follows the oracle's single-current-squad behavior:
/// every numeric id returns the one Core-backed active squad.
pub fn classify(method: &str, path: &str) -> Route {
    let get = method.eq_ignore_ascii_case("GET");
    let put = method.eq_ignore_ascii_case("PUT");
    let post = method.eq_ignore_ascii_case("POST");
    // Session/capability vertical (Rust session authority).
    if post && path.starts_with("/ut/auth") {
        return Route::Auth;
    }
    // Rust owns the /ut/delete/auth logout ack (any method).
    if path.starts_with("/ut/delete/auth") {
        return Route::Auth;
    }
    // Launcher control-plane account summary (not under /ut/game).
    if post && path == "/openfut/account/sync" {
        return Route::AccountSync;
    }
    if post && path == "/openfut/fifa17/capability" {
        return Route::Capability;
    }
    if get && is_exact_club_path(path) {
        return Route::Club;
    }
    if put && is_exact_club_path(path) {
        return Route::ClubRename;
    }
    match ut_tail(path) {
        Some("squad/list") if get => Route::SquadList,
        Some("squad/active") if get => Route::SquadActive,
        Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive,
        Some("userMassInfo") if get => Route::UserMassInfo,
        Some("user/club") if put || post => Route::ClubRename,
        Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
        Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
        Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
        Some("user/accountinfo") if get => Route::AccountInfo,
        Some("user") if get => Route::User,
        Some("settings") if get => Route::Settings,
        Some("leaderboards/options") if get => Route::LeaderboardOptions,
        Some("match/reset") if put => Route::MatchReset,
        Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion,
        Some("club/stats/staff") if get => Route::ClubStatsStaff,
        Some(t) if get && t.starts_with("club/stats/") => Route::ClubStats,
        Some("hub") if get => Route::Hub,
        Some("store") => Route::StaticAck,
        Some("match/keepalive") => Route::StaticAck,
        Some("captcha") if get => Route::StaticAck,
        Some("tfa") => Route::StaticAck,
        Some("livemessage") => Route::StaticAck,
        Some("activeMessage") => Route::StaticAck,
        Some("watchList") => Route::WatchList,
        Some("season") if get => Route::FeatureOffEmpty,
        Some("tournament") if get => Route::FeatureOffEmpty,
        Some("champion") if get => Route::FeatureOffEmpty,
        Some("clubUser") if get => Route::FeatureOffEmpty,
        Some("user/list") if get => Route::FeatureOffEmpty,
        Some("item/resource") if get => Route::ItemDefs,
        Some("defid") if get => Route::ItemDefs,
        Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData,
        _ => Route::Passthrough,
    }
}

/// The tail after `/ut/game/<sku>/` or `/ut/v2/game/<sku>/` (non-empty sku), or
/// `None`. Retail FIFA 17 issues the Store family (`store/*`, `purchased`) under
/// the `/ut/v2/game/<sku>/` prefix while other routes use `/ut/game/<sku>/`; both
/// normalize to the same tail so economy classification is prefix-agnostic. The
/// `sku` segment is generic (never hard-coded to `fifa17`).
fn ut_tail(path: &str) -> Option<&str> {
    let rest = path
        .strip_prefix("/ut/game/")
        .or_else(|| path.strip_prefix("/ut/v2/game/"))?;
    let (sku, tail) = rest.split_once('/')?;
    if sku.is_empty() {
        None
    } else {
        Some(tail)
    }
}

/// Every run of ≥ 3 ASCII digits in `s`, parsed as `i64` — the allocation-light
/// equivalent of the oracle's `re.findall(r"\d{3,}", query)` used by `defs_route`
/// to pull ids out of `resourceId=`/`definitionId=`/`idList=a,b,c` queries.
fn extract_long_ints(s: &str) -> Vec<i64> {
    let bytes = s.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i].is_ascii_digit() {
            let start = i;
            while i < bytes.len() && bytes[i].is_ascii_digit() {
                i += 1;
            }
            if i - start >= 3 {
                if let Ok(v) = s[start..i].parse::<i64>() {
                    out.push(v);
                }
            }
        } else {
            i += 1;
        }
    }
    out
}

/// The comma-separated all-digit values of the `defId=` query parameter (the
/// oracle's `parse_qs(...).get("defId")` + `int(x) for x if x.isdigit()`), used
/// by `/marketdata/pricelimits`. Non-digit tokens are skipped, never faked.
fn extract_defid_param(query: &str) -> Vec<i64> {
    for pair in query.split('&') {
        if let Some(v) = pair.strip_prefix("defId=") {
            return v
                .split(',')
                .filter_map(|t| {
                    let t = t.trim();
                    if !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()) {
                        t.parse::<i64>().ok()
                    } else {
                        None
                    }
                })
                .collect();
        }
    }
    Vec::new()
}

fn is_exact_club_path(path: &str) -> bool {
    ut_tail(path) == Some("club")
}

/// `squad/<digits>` — the numeric full-squad target used by PUT and GET. Core
/// stores one current squad, matching the oracle: every numeric GET returns that
/// same squad regardless of the requested id.
fn is_numeric_squad_tail(tail: &str) -> bool {
    match tail.strip_prefix("squad/") {
        Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
        None => false,
    }
}

/// A FIFA17 economy route, classified separately from [`classify`]. This is the
/// *target* ownership map for the economy cutover. It is deliberately NOT wired
/// into [`Server::handle_with_ip`] yet: handler wiring and authority cutover are
/// distinct steps. Until the single barrier commit flips the whole cluster,
/// production classification ([`classify`]) still sends every one of these to
/// Python; only integration tests drive them through [`Server::try_handle_economy`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EconomyRoute {
    /// `GET …/user/credits` — coins + unopened-pack count.
    Credits,
    /// `GET …/store/purchasegroup` — catalogue + owned packs, full-generated.
    PurchaseGroup,
    /// `PUT …/store/transaction` — Store BUY (open-on-buy).
    StoreBuy,
    /// `POST …/purchased` — open a pack / redeem an owned entitlement.
    PackOpen,
    /// `GET …/purchased` — the pack-reveal screen (items in the "purchased" pile).
    PackReveal,
    /// `DELETE …/item/<id>` — single-card quick-sell.
    QuickSellPath,
    /// `POST /ut/delete/game/<sku>/item` — bulk quick-sell.
    QuickSellBody,
    /// `PUT …/item` — FutMoveCard pile move.
    MoveItems,
    /// `POST /ut/delete/game/<sku>/match` — match END (the coin-crediting call).
    MatchEnd,
    /// `…/auctionhouse` | `…/transfermarket` — list-for-sale / browse.
    MarketList,
    /// `GET …/tradePile` — the user's own active listings.
    MarketQuery,
    /// `GET …/tradePile/counts` — FutGetAuctionCount, the auction TALLY. A
    /// DISTINCT deserializer from `/tradePile`: it reads five scalar ints and
    /// skips everything else, so answering it with the `auctionInfo` listing body
    /// leaves every count at its constructor default (0) and the Transfer List
    /// screen shows no active sale even while the hub tile shows one.
    MarketCounts,
    /// `GET …/trade/status` — live auction-state refresh, polled continuously by
    /// the Transfer List. MUST be classified before [`Self::MarketBuy`]: `status`
    /// is not a numeric trade id, so the buy/view arm answers every poll with an
    /// empty `auctionInfo` and the screen never learns its own auctions' state.
    MarketStatus,
    /// `…/trade/<id>` — view / buy-now.
    MarketBuy,
    /// Cancel a listing. Both `DELETE /ut/delete/game/<sku>/trade/<id>` (the
    /// oracle's spelling) and plain `DELETE /ut/game/<sku>/trade/<id>` (what
    /// contemporaneous FIFA 17 clients use) map here — a DELETE on the plain
    /// spelling otherwise fell into the buy/view arm and silently did nothing.
    MarketCancel,
    /// Bulk clear-sold: `DELETE /ut/delete/game/<sku>/trade/sold`, the client's
    /// `RemoveAllSoldFromTradePile`. Carries no trade id, so it MUST NOT reach
    /// [`EconomyRoute::MarketCancel`], which would parse no id and ack while
    /// clearing nothing.
    MarketClearSold,
    /// `GET …/sbs/sets` — SBC category/set list from Core definitions.
    SbcSets,
    /// `POST/PUT …/sbs/sets/tag` — stateless tag acknowledgement.
    SbcTag,
    /// `GET …/sbs/setId/<id>/challenges` — challenges in one set.
    SbcChallenges,
    /// `GET/PUT …/sbs/challenge/<id>/squad` — durable working squad.
    SbcChallengeSquad,
    /// `POST/PUT …/sbs/challenge/<id>` — start or atomic submission.
    SbcChallenge,
}

/// `item/<digits>` — the single-card quick-sell tail (DELETE).
fn is_item_id_tail(tail: &str) -> bool {
    match tail.strip_prefix("item/") {
        Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
        None => false,
    }
}

/// `store/transaction` or `store/transaction/<digits>` — the Store BUY create
/// step. Retail sends a trailing numeric transaction id (observed live:
/// `store/transaction/0`). Mirrors the Python oracle's bare `/store/transaction`
/// route, but bounded to a single all-digit id segment so it never absorbs
/// `store/transactions`, `store/transactionfoo`, or `store/transaction/0/extra`.
fn is_store_transaction_tail(tail: &str) -> bool {
    match tail.strip_prefix("store/transaction") {
        Some("") => true,
        Some(rest) => match rest.strip_prefix('/') {
            Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
            None => false,
        },
        None => false,
    }
}

/// `purchased` or `purchased/items` — pack OPEN (POST) / reveal (GET). Retail
/// sends the `/items` sub-path (FutPurchaseItemsServerResponse); the Python
/// oracle's bare `/purchased` regex matches both. Bounded to exactly these two
/// tails (rejects `purchasedfoo`, `purchased/items/extra`).
fn is_purchased_tail(tail: &str) -> bool {
    tail == "purchased" || tail == "purchased/items"
}

/// `tradePile` or `tradePile/counts` — the user's own listings (query) + the
/// listing-count tile. CASE-INSENSITIVE: the FUT hub tile polls lowercase
/// `tradepile`/`tradepile/counts` while the screen uses camelCase `tradePile`
/// (the oracle routes both via `re.I`). Bounded to the `tradepile` family
/// (base tail or a `tradepile/<sub>` path); allocation-free.
fn is_tradepile_tail(tail: &str) -> bool {
    const BASE: &str = "tradePile";
    match tail.len() {
        9 => tail.eq_ignore_ascii_case(BASE),
        n if n > 9 => tail.as_bytes()[9] == b'/' && tail[..9].eq_ignore_ascii_case(BASE),
        _ => false,
    }
}

/// `tradePile/counts` exactly (case-insensitive) — the auction-tally sub-path,
/// which MUST be classified before [`is_tradepile_tail`] because that matcher
/// also accepts it (the two endpoints have different response shapes).
fn is_tradepile_counts_tail(tail: &str) -> bool {
    const COUNTS: &str = "tradePile/counts";
    tail.eq_ignore_ascii_case(COUNTS)
}

/// `trade/status` exactly (case-insensitive) — the live auction-state poll. MUST
/// be classified before the generic `trade…` buy/view arm: `status` is not a
/// numeric trade id, so that arm degrades every poll to an empty `auctionInfo`.
fn is_trade_status_tail(tail: &str) -> bool {
    const STATUS: &str = "trade/status";
    tail.eq_ignore_ascii_case(STATUS)
}

/// `trade/sold` — the BULK clear-sold tail, which carries no trade id.
///
/// PE-proven shape: request builder `0x1801647c0` writes the literal `/sold` when
/// its tradeId field is zero and `/%lld` when it is not, onto route base
/// `ut/delete/%s/trade`. The client names the operation
/// `RemoveAllSoldFromTradePile`.
fn is_trade_sold_tail(tail: &str) -> bool {
    tail.eq_ignore_ascii_case("trade/sold")
}
fn bounded_numeric_segment<'a>(tail: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
    let value = tail.strip_prefix(prefix)?.strip_suffix(suffix)?;
    if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) {
        Some(value)
    } else {
        None
    }
}

fn sbc_challenge_id(tail: &str) -> Option<i64> {
    bounded_numeric_segment(tail, "sbs/challenge/", "")
        .or_else(|| bounded_numeric_segment(tail, "sbs/challenge/", "/squad"))?
        .parse()
        .ok()
}

fn sbc_set_id(tail: &str) -> Option<i64> {
    bounded_numeric_segment(tail, "sbs/setId/", "/challenges")?
        .parse()
        .ok()
}

/// Classify a FIFA17 economy route from method + path, mirroring the Python
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
/// non-economy path. Path is already query-stripped by the caller.
pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
    let get = method.eq_ignore_ascii_case("GET");
    let put = method.eq_ignore_ascii_case("PUT");
    let post = method.eq_ignore_ascii_case("POST");
    let delete = method.eq_ignore_ascii_case("DELETE");

    // The `/ut/delete/game/<sku>/…` family is NOT `/ut/game/…`-prefixed.
    if let Some(rest) = path
        .strip_prefix("/ut/delete/game/")
        .or_else(|| path.strip_prefix("/ut/v2/delete/game/"))
    {
        if let Some((_sku, tail)) = rest.split_once('/') {
            if tail == "item" && post {
                return Some(EconomyRoute::QuickSellBody);
            }
            // MUST precede the generic `trade…` cancel arm. FIFA 17's request
            // builder 0x1801647c0 emits the literal `/sold` (no numeric id) for the
            // bulk "RemoveAllSoldFromTradePile" verb, and `/%lld` for one trade. A
            // `sold` tail carries no id, so the cancel handler would parse nothing
            // and silently ack while clearing nothing.
            if delete && is_trade_sold_tail(tail) {
                return Some(EconomyRoute::MarketClearSold);
            }
            if tail.starts_with("trade") && delete {
                return Some(EconomyRoute::MarketCancel);
            }
            if tail == "match" && post {
                return Some(EconomyRoute::MatchEnd);
            }
        }
        return None;
    }

    match ut_tail(path) {
        Some("user/credits") if get => Some(EconomyRoute::Credits),
        Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup),
        Some(t) if put && is_store_transaction_tail(t) => Some(EconomyRoute::StoreBuy),
        Some(t) if post && is_purchased_tail(t) => Some(EconomyRoute::PackOpen),
        Some(t) if get && is_purchased_tail(t) => Some(EconomyRoute::PackReveal),
        Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
        Some("item") if put => Some(EconomyRoute::MoveItems),
        Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList),
        // MUST precede the base tradePile arm: that matcher also accepts
        // `tradePile/counts`, but the tally is a different deserializer.
        Some(t) if get && is_tradepile_counts_tail(t) => Some(EconomyRoute::MarketCounts),
        Some(t) if get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery),
        // BOTH must precede the buy/view arm, which swallows any `trade…` tail:
        // `trade/status` has no numeric id (so it answered polls with an empty
        // auctionInfo), and a plain DELETE fell in as a no-op "view".
        Some(t) if get && is_trade_status_tail(t) => Some(EconomyRoute::MarketStatus),
        Some(t) if delete && t.starts_with("trade") => Some(EconomyRoute::MarketCancel),
        Some("sbs/sets") if get => Some(EconomyRoute::SbcSets),
        Some("sbs/sets/tag") if post || put => Some(EconomyRoute::SbcTag),
        Some(t) if get && sbc_set_id(t).is_some() => Some(EconomyRoute::SbcChallenges),
        Some(t)
            if (get || put) && bounded_numeric_segment(t, "sbs/challenge/", "/squad").is_some() =>
        {
            Some(EconomyRoute::SbcChallengeSquad)
        }
        Some(t) if (post || put) && bounded_numeric_segment(t, "sbs/challenge/", "").is_some() => {
            Some(EconomyRoute::SbcChallenge)
        }
        Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy),
        _ => None,
    }
}

// ───────────────────────────── Core access boundary ─────────────────────────

/// Failure reaching or reading OpenFUT Core.
#[derive(Debug)]
pub enum CoreError {
    Http(String),
    Status(u16),
    Parse(String),
}

impl std::fmt::Display for CoreError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CoreError::Http(e) => write!(f, "core http error: {e}"),
            CoreError::Status(s) => write!(f, "core returned status {s}"),
            CoreError::Parse(e) => write!(f, "core response parse error: {e}"),
        }
    }
}

/// One page of owned items plus the filtered total, as returned by Core.
pub struct CorePage {
    pub items: Vec<CoreOwnedItem>,
    pub total: i64,
}

/// One canonical squad slot as read back from Core (game-independent).
#[derive(Debug, Clone)]
pub struct CoreSquadSlot {
    pub owned_card_id: String,
    pub index: i64,
    pub is_captain: bool,
    pub is_on_bench: bool,
}

/// Freshness of the stored opaque extension vs the current canonical squad, as
/// Core reports it. Carries the stored payload for Fresh/Stale (never applied
/// when Stale — the host decides policy).
#[derive(Debug, Clone)]
pub enum CoreExtState {
    Fresh {
        schema_version: i64,
        payload: String,
    },
    Stale {
        schema_version: i64,
        payload: String,
    },
    Missing,
}

/// The active squad, its canonical slots, and its opaque extension state — the
/// result of Core's `GET /squad/ext`.
#[derive(Debug, Clone)]
pub struct CoreSquadRead {
    pub name: String,
    /// FIFA formation token, verbatim (Core stores it opaquely).
    pub formation: String,
    pub slots: Vec<CoreSquadSlot>,
    pub ext: CoreExtState,
}

/// A canonical + extension squad replacement the host asks Core to commit
/// atomically (`PUT /squad/replace`).
pub struct CoreReplaceRequest {
    pub name: Option<String>,
    pub formation: Option<String>,
    pub slots: Vec<CoreSquadSlot>,
    pub client_reported: CoreClientEval,
    pub ext_namespace: String,
    pub ext_schema_version: i64,
    pub ext_payload: String,
}

/// Client-reported shadow evaluation carried through to Core (never Core's
/// authoritative evaluation).
#[derive(Debug, Clone, Default)]
pub struct CoreClientEval {
    pub chemistry: Option<i64>,
    pub rating: Option<i64>,
    pub star_rating: Option<i64>,
}

/// Outcome of a committed replacement.
#[derive(Debug, Clone)]
pub struct CoreReplaceResult {
    pub squad_id: String,
    pub canonical_fingerprint: String,
    pub slots_written: usize,
}

/// How the host reaches Core. The adapter never sees this — the host owns the
/// transport, mirroring the architecture rule. Tests inject a fake.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreSbcDefinition {
    pub id: String,
    pub name: String,
    pub description: String,
    pub repeatable: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreSbcResult {
    pub passed: bool,
    pub failures: Vec<String>,
}

pub trait CoreAccess: Send + Sync {
    /// Query the owned inventory with semantic `/collection` query params.
    fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError>;

    /// Every owned item for the active club, in one call (no pagination) — used
    /// to assemble a whole squad projection and to authorize squad writes. The
    /// default delegates to an unfiltered `query_owned`.
    fn all_owned(&self) -> Result<Vec<CoreOwnedItem>, CoreError> {
        Ok(self.query_owned(&[])?.items)
    }

    /// Every card DEFINITION in Core content (`GET /cards`) — the full card
    /// universe a pack can draw from, independent of ownership. Same shape as
    /// owned items (rating/position/nation/league/club/attributes) so
    /// [`build_content_pool`] treats definitions and owned items uniformly.
    /// Default: unimplemented (callers fall back to owned inventory).
    fn all_definitions(&self) -> Result<Vec<CoreOwnedItem>, CoreError> {
        Err(CoreError::Parse(
            "Core content enumeration is not implemented".into(),
        ))
    }

    /// Read the active squad + its opaque extension for `namespace`.
    fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError>;

    /// Replace the active squad's canonical slots + opaque extension atomically.
    fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;

    /// The owned instance id assigned as the active squad's **manager**, or
    /// `None` (`GET /club/manager`). Default: `None` — a transport without the
    /// endpoint simply projects no manager (non-fatal, like an absent
    /// assignment). The production `HttpCoreClient` overrides it.
    fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
        Ok(None)
    }

    /// Assign (`Some`) or clear (`None`) the active squad's **manager**
    /// (`PUT /club/manager`). Default: unimplemented — the production
    /// `HttpCoreClient` overrides it; a transport that cannot persist the
    /// assignment MUST fail loudly rather than silently drop it.
    fn set_squad_manager(&self, _owned_card_id: Option<&str>) -> Result<(), CoreError> {
        Err(CoreError::Parse(
            "Core squad manager write is not implemented".into(),
        ))
    }

    fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
        Err(CoreError::Parse(
            "Core SBC access is not implemented".into(),
        ))
    }

    fn sbc_completion_counts(&self) -> Result<std::collections::HashMap<String, i64>, CoreError> {
        Err(CoreError::Parse(
            "Core SBC access is not implemented".into(),
        ))
    }

    fn load_sbc_squad(&self, _sbc_id: &str) -> Result<Vec<String>, CoreError> {
        Err(CoreError::Parse(
            "Core SBC access is not implemented".into(),
        ))
    }

    fn save_sbc_squad(
        &self,
        _sbc_id: &str,
        _owned_card_ids: &[String],
    ) -> Result<Vec<String>, CoreError> {
        Err(CoreError::Parse(
            "Core SBC access is not implemented".into(),
        ))
    }

    fn submit_sbc(
        &self,
        _sbc_id: &str,
        _owned_card_ids: &[String],
    ) -> Result<CoreSbcResult, CoreError> {
        Err(CoreError::Parse(
            "Core SBC access is not implemented".into(),
        ))
    }
}

/// Default HTTP implementation: `GET {core_url}/collection?…` (plain HTTP JSON,
/// the same boundary Bridge uses to reach Core). Every request carries
/// `X-OpenFUT-Game: <game>` so Core resolves the game-scoped active profile — for
/// FIFA 17 that is the profile whose inventory is fully asset-mapped, so `/club`
/// filters/paginates a wholly renderable set (no post-pagination drops).
pub struct HttpCoreClient {
    base_url: String,
    game: String,
    client: reqwest::blocking::Client,
}

impl HttpCoreClient {
    pub fn new(base_url: impl Into<String>, game: impl Into<String>) -> Self {
        HttpCoreClient {
            base_url: base_url.into().trim_end_matches('/').to_string(),
            game: game.into(),
            client: reqwest::blocking::Client::new(),
        }
    }
}

impl CoreAccess for HttpCoreClient {
    fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> {
        let url = format!("{}/collection", self.base_url);
        let resp = self
            .client
            .get(&url)
            .header("X-OpenFUT-Game", &self.game)
            .query(params)
            .send()
            .map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
        parse_core_page(&v)
    }

    fn all_definitions(&self) -> Result<Vec<CoreOwnedItem>, CoreError> {
        let url = format!("{}/cards", self.base_url);
        let resp = self
            .client
            .get(&url)
            .header("X-OpenFUT-Game", &self.game)
            .send()
            .map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
        parse_core_definitions(&v)
    }
    fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError> {
        let url = format!("{}/squad/ext", self.base_url);
        let resp = self
            .client
            .get(&url)
            .header("X-OpenFUT-Game", &self.game)
            .query(&[("namespace", namespace)])
            .send()
            .map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
        parse_core_squad_read(&v)
    }

    fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> {
        let url = format!("{}/squad/replace", self.base_url);
        let resp = self
            .client
            .put(&url)
            .header("X-OpenFUT-Game", &self.game)
            .json(&replace_request_body(req))
            .send()
            .map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
        Ok(CoreReplaceResult {
            squad_id: v
                .get("squad_id")
                .and_then(|x| x.as_str())
                .unwrap_or("")
                .to_string(),
            canonical_fingerprint: v
                .get("canonical_fingerprint")
                .and_then(|x| x.as_str())
                .unwrap_or("")
                .to_string(),
            slots_written: v.get("slots_written").and_then(|x| x.as_u64()).unwrap_or(0) as usize,
        })
    }

    fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
        let url = format!("{}/club/manager", self.base_url);
        let resp = self
            .client
            .get(&url)
            .header("X-OpenFUT-Game", &self.game)
            .send()
            .map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
        Ok(v.get("manager")
            .and_then(|m| m.get("owned_card_id"))
            .and_then(|x| x.as_str())
            .map(str::to_string))
    }

    fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
        let url = format!("{}/club/manager", self.base_url);
        let resp = self
            .client
            .put(&url)
            .header("X-OpenFUT-Game", &self.game)
            .json(&json!({ "owned_card_id": owned_card_id }))
            .send()
            .map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        Ok(())
    }

    fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
        let response = self
            .client
            .get(format!("{}/sbc", self.base_url))
            .header("X-OpenFUT-Game", &self.game)
            .send()
            .map_err(|error| CoreError::Http(error.to_string()))?;
        let status = response.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        let body: Value = response
            .json()
            .map_err(|error| CoreError::Parse(error.to_string()))?;
        body.get("sbcs")
            .and_then(Value::as_array)
            .ok_or_else(|| CoreError::Parse("missing `sbcs` array".into()))?
            .iter()
            .map(|definition| {
                Ok(CoreSbcDefinition {
                    id: json_str(definition, "id")?,
                    name: json_str(definition, "name")?,
                    description: json_str(definition, "description")?,
                    repeatable: definition
                        .get("repeatable")
                        .and_then(Value::as_bool)
                        .unwrap_or(false),
                })
            })
            .collect()
    }

    fn sbc_completion_counts(&self) -> Result<std::collections::HashMap<String, i64>, CoreError> {
        let response = self
            .client
            .get(format!("{}/sbc/status", self.base_url))
            .header("X-OpenFUT-Game", &self.game)
            .send()
            .map_err(|error| CoreError::Http(error.to_string()))?;
        let status = response.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        let body: Value = response
            .json()
            .map_err(|error| CoreError::Parse(error.to_string()))?;
        let completions = body
            .get("completions")
            .and_then(Value::as_object)
            .ok_or_else(|| CoreError::Parse("missing `completions` object".into()))?;
        completions
            .iter()
            .map(|(id, value)| {
                value
                    .as_i64()
                    .map(|count| (id.clone(), count))
                    .ok_or_else(|| CoreError::Parse(format!("invalid completion count for {id}")))
            })
            .collect()
    }

    fn load_sbc_squad(&self, sbc_id: &str) -> Result<Vec<String>, CoreError> {
        let response = self
            .client
            .get(format!("{}/sbc/{sbc_id}/squad", self.base_url))
            .header("X-OpenFUT-Game", &self.game)
            .send()
            .map_err(|error| CoreError::Http(error.to_string()))?;
        parse_core_sbc_squad_response(response)
    }

    fn save_sbc_squad(
        &self,
        sbc_id: &str,
        owned_card_ids: &[String],
    ) -> Result<Vec<String>, CoreError> {
        let response = self
            .client
            .put(format!("{}/sbc/{sbc_id}/squad", self.base_url))
            .header("X-OpenFUT-Game", &self.game)
            .json(&json!({ "owned_card_ids": owned_card_ids }))
            .send()
            .map_err(|error| CoreError::Http(error.to_string()))?;
        parse_core_sbc_squad_response(response)
    }

    fn submit_sbc(
        &self,
        sbc_id: &str,
        owned_card_ids: &[String],
    ) -> Result<CoreSbcResult, CoreError> {
        let response = self
            .client
            .post(format!("{}/sbc/submit", self.base_url))
            .header("X-OpenFUT-Game", &self.game)
            .json(&json!({ "sbc_id": sbc_id, "owned_card_ids": owned_card_ids }))
            .send()
            .map_err(|error| CoreError::Http(error.to_string()))?;
        let status = response.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        let body: Value = response
            .json()
            .map_err(|error| CoreError::Parse(error.to_string()))?;
        Ok(CoreSbcResult {
            passed: body
                .get("passed")
                .and_then(Value::as_bool)
                .ok_or_else(|| CoreError::Parse("missing SBC `passed` bool".into()))?,
            failures: body
                .get("failures")
                .and_then(Value::as_array)
                .ok_or_else(|| CoreError::Parse("missing SBC `failures` array".into()))?
                .iter()
                .filter_map(Value::as_str)
                .map(str::to_owned)
                .collect(),
        })
    }
}

fn parse_core_sbc_squad_response(
    response: reqwest::blocking::Response,
) -> Result<Vec<String>, CoreError> {
    let status = response.status().as_u16();
    if !(200..300).contains(&status) {
        return Err(CoreError::Status(status));
    }
    let body: Value = response
        .json()
        .map_err(|error| CoreError::Parse(error.to_string()))?;
    body.get("squad")
        .and_then(|squad| squad.get("owned_card_ids"))
        .and_then(Value::as_array)
        .ok_or_else(|| CoreError::Parse("missing SBC squad `owned_card_ids` array".into()))?
        .iter()
        .map(|id| {
            id.as_str()
                .map(str::to_owned)
                .ok_or_else(|| CoreError::Parse("SBC owned-card id is not a string".into()))
        })
        .collect()
}

// ───────────────────────────── Core economy boundary ────────────────────────

/// One unconsumed entitlement Core reports for the active club.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomyEntitlement {
    pub id: String,
    pub definition_id: String,
}

/// Outcome of a purchase: post-debit balance + the new entitlement id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomyPurchase {
    pub balance: i64,
    pub entitlement_id: String,
}

/// An item to place into inventory on entitlement redemption. `item_id` is the
/// caller-minted opaque Core instance id (the adapter maps it to/from the FIFA
/// numeric wire id via the identity store); `card_id` is the definition ref.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomyGrantItem {
    pub item_id: String,
    pub card_id: String,
}

/// Terms of a completed market sale, for [`CoreEconomy::settle_sale`]. `gross`
/// is what the buyer pays; `fee` is the market cut destroyed on settlement, so
/// the seller nets `gross - fee`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomySale<'a> {
    pub item_id: &'a str,
    /// Club the caller believes owns the item; `None` -> Core's game-scoped
    /// active club. Core predicates the ownership move on this club, so a
    /// wrong (or already-settled) seller is rejected rather than silently
    /// re-run — this doubles as the replay guard.
    pub seller_club_id: Option<&'a str>,
    /// Acquiring club; `None` -> a counterparty outside the modelled economy:
    /// nobody is debited and the item is destroyed.
    pub buyer_club_id: Option<&'a str>,
    pub gross: i64,
    pub fee: i64,
}

/// What Core did when settling a sale: the post-settlement balances of both
/// sides (`buyer_balance` is `None` for an outside buyer) and how many squad
/// slots the sold item vacated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomySaleReceipt {
    pub item_id: String,
    pub card_id: String,
    pub seller_club_id: String,
    pub buyer_club_id: Option<String>,
    pub gross: i64,
    pub fee: i64,
    pub proceeds: i64,
    pub seller_balance: i64,
    pub buyer_balance: Option<i64>,
    pub squad_slots_freed: u64,
}

/// A finished match to apply to Core's authoritative, exactly-once
/// `complete_match` transaction. `match_identity` is the durable per-match
/// idempotency key (persona-scoped); `result` is the canonical Core token
/// (`win`/`draw`/`loss`/`dnf`/`no_contest`) the adapter derived from `endReason`.
pub struct CoreMatchCompletion<'a> {
    pub match_identity: &'a str,
    pub result: &'a str,
    pub squad_id: &'a str,
    pub opponent_name: &'a str,
    pub goals_for: i64,
    pub goals_against: i64,
    pub mode: &'a str,
}

/// Core's authoritative answer for a match completion. `applied` is `false` on an
/// idempotent replay; the coin figures are Core's, rendered straight onto the
/// wire reward body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreMatchReceipt {
    pub applied: bool,
    pub result: String,
    pub coins_awarded: i64,
    pub coins_balance: i64,
}

/// The host's authoritative economy transport to Core. Every method is a single
/// durable Core transaction. **Fail-closed:** on any transport/status/parse
/// error the caller MUST surface a controlled error and NEVER fall back to
/// Python — a Python fallback would reintroduce a second writer.
pub trait CoreEconomy: Send + Sync {
    fn balance(&self) -> Result<i64, CoreError>;
    fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError>;
    fn purchase_entitlement(
        &self,
        cost: i64,
        definition_id: &str,
    ) -> Result<EconomyPurchase, CoreError>;
    fn redeem_entitlement(
        &self,
        entitlement_id: &str,
        items: &[EconomyGrantItem],
    ) -> Result<String, CoreError>;
    fn sell_item(&self, item_id: &str, price: i64) -> Result<i64, CoreError>;
    fn grant_reward(&self, amount: i64) -> Result<i64, CoreError>;
    fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result<i64, CoreError>;
    /// Debit `cost` and mint several items atomically (open-on-buy Store packs).
    fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError>;
    /// Settle a completed market sale in ONE Core transaction: evict the item
    /// from every squad, move ownership from the seller to `buyer_club_id`
    /// (or destroy it for an outside buyer), debit a club buyer `gross` and
    /// credit the seller `gross - fee`. Core rejects a sale whose named seller
    /// does not own the item, so a replayed settlement is refused, never
    /// double-paid.
    fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError>;
    /// Apply a finished match to Core's authoritative, atomic, exactly-once
    /// `complete_match` transaction. Core is the sole economy writer here — a
    /// replay/duplicate returns `applied = false` with the canonical result, and
    /// any error is surfaced (never a Python fallback).
    fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError>;
}

impl HttpCoreClient {
    fn economy_url(&self, tail: &str) -> String {
        format!("{}/economy/{}", self.base_url, tail)
    }

    fn economy_get(&self, tail: &str) -> Result<Value, CoreError> {
        let resp = self
            .client
            .get(self.economy_url(tail))
            .header("X-OpenFUT-Game", &self.game)
            .send()
            .map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        resp.json().map_err(|e| CoreError::Parse(e.to_string()))
    }

    fn economy_post(&self, tail: &str, body: &Value) -> Result<Value, CoreError> {
        let resp = self
            .client
            .post(self.economy_url(tail))
            .header("X-OpenFUT-Game", &self.game)
            .json(body)
            .send()
            .map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        resp.json().map_err(|e| CoreError::Parse(e.to_string()))
    }

    /// POST to a non-`/economy/` Core endpoint (e.g. `matches/complete`), same
    /// game header + status/parse handling as [`Self::economy_post`].
    fn core_post(&self, tail: &str, body: &Value) -> Result<Value, CoreError> {
        let resp = self
            .client
            .post(format!("{}/{}", self.base_url, tail))
            .header("X-OpenFUT-Game", &self.game)
            .json(body)
            .send()
            .map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        if !(200..300).contains(&status) {
            return Err(CoreError::Status(status));
        }
        resp.json().map_err(|e| CoreError::Parse(e.to_string()))
    }
}

fn json_i64(v: &Value, key: &str) -> Result<i64, CoreError> {
    v.get(key)
        .and_then(Value::as_i64)
        .ok_or_else(|| CoreError::Parse(format!("missing i64 field `{key}`")))
}

fn json_str(v: &Value, key: &str) -> Result<String, CoreError> {
    v.get(key)
        .and_then(Value::as_str)
        .map(str::to_string)
        .ok_or_else(|| CoreError::Parse(format!("missing string field `{key}`")))
}

fn json_u64(v: &Value, key: &str) -> Result<u64, CoreError> {
    v.get(key)
        .and_then(Value::as_u64)
        .ok_or_else(|| CoreError::Parse(format!("missing u64 field `{key}`")))
}

impl CoreEconomy for HttpCoreClient {
    fn balance(&self) -> Result<i64, CoreError> {
        json_i64(&self.economy_get("balance")?, "balance")
    }

    fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError> {
        let v = self.economy_get("entitlements")?;
        let arr = v
            .as_array()
            .ok_or_else(|| CoreError::Parse("entitlements not an array".into()))?;
        arr.iter()
            .map(|e| {
                Ok(EconomyEntitlement {
                    id: json_str(e, "id")?,
                    definition_id: json_str(e, "definition_id")?,
                })
            })
            .collect()
    }

    fn purchase_entitlement(
        &self,
        cost: i64,
        definition_id: &str,
    ) -> Result<EconomyPurchase, CoreError> {
        let v = self.economy_post(
            "purchase-entitlement",
            &json!({ "cost": cost, "definition_id": definition_id }),
        )?;
        Ok(EconomyPurchase {
            balance: json_i64(&v, "balance")?,
            entitlement_id: json_str(&v, "entitlement_id")?,
        })
    }

    fn redeem_entitlement(
        &self,
        entitlement_id: &str,
        items: &[EconomyGrantItem],
    ) -> Result<String, CoreError> {
        let items_json: Vec<Value> = items
            .iter()
            .map(|i| json!({ "item_id": i.item_id, "card_id": i.card_id }))
            .collect();
        let v = self.economy_post(
            "redeem-entitlement",
            &json!({ "entitlement_id": entitlement_id, "items": items_json }),
        )?;
        json_str(&v, "definition_id")
    }

    fn sell_item(&self, item_id: &str, price: i64) -> Result<i64, CoreError> {
        let v = self.economy_post("sell-item", &json!({ "item_id": item_id, "price": price }))?;
        json_i64(&v, "balance")
    }

    fn grant_reward(&self, amount: i64) -> Result<i64, CoreError> {
        let v = self.economy_post("grant-reward", &json!({ "amount": amount }))?;
        json_i64(&v, "balance")
    }

    fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result<i64, CoreError> {
        let v = self.economy_post(
            "purchase-item",
            &json!({ "cost": cost, "item_id": item_id, "card_id": card_id }),
        )?;
        json_i64(&v, "balance")
    }

    fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError> {
        let items_json: Vec<Value> = items
            .iter()
            .map(|i| json!({ "item_id": i.item_id, "card_id": i.card_id }))
            .collect();
        let v = self.economy_post(
            "purchase-items",
            &json!({ "cost": cost, "items": items_json }),
        )?;
        json_i64(&v, "balance")
    }

    fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
        let v = self.economy_post("settle-sale", &sale_request_body(sale))?;
        Ok(EconomySaleReceipt {
            item_id: json_str(&v, "item_id")?,
            card_id: json_str(&v, "card_id")?,
            seller_club_id: json_str(&v, "seller_club_id")?,
            // Absent or null both mean "no club buyer": the outside-sale case.
            buyer_club_id: v
                .get("buyer_club_id")
                .and_then(Value::as_str)
                .map(str::to_string),
            gross: json_i64(&v, "gross")?,
            fee: json_i64(&v, "fee")?,
            proceeds: json_i64(&v, "proceeds")?,
            seller_balance: json_i64(&v, "seller_balance")?,
            buyer_balance: v.get("buyer_balance").and_then(Value::as_i64),
            squad_slots_freed: json_u64(&v, "squad_slots_freed")?,
        })
    }

    fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
        let v = self.core_post(
            "matches/complete",
            &json!({
                "match_identity": m.match_identity,
                "result": m.result,
                "squad_id": m.squad_id,
                "opponent_name": m.opponent_name,
                "goals_for": m.goals_for,
                "goals_against": m.goals_against,
                "mode": m.mode,
            }),
        )?;
        Ok(CoreMatchReceipt {
            applied: v.get("applied").and_then(Value::as_bool).unwrap_or(false),
            result: json_str(&v, "result")?,
            coins_awarded: json_i64(&v, "coins_awarded")?,
            coins_balance: json_i64(&v, "coins_balance")?,
        })
    }
}

/// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body.
/// The two club fields are OMITTED when `None` — their absence is what selects
/// Core's defaults (the game-scoped active club as seller, a counterparty
/// outside the modelled economy as buyer).
pub fn sale_request_body(sale: &EconomySale<'_>) -> Value {
    let mut body = json!({
        "item_id": sale.item_id,
        "gross": sale.gross,
        "fee": sale.fee,
    });
    let obj = body
        .as_object_mut()
        .expect("the literal above is a JSON object");
    if let Some(seller) = sale.seller_club_id {
        obj.insert("seller_club_id".into(), Value::from(seller));
    }
    if let Some(buyer) = sale.buyer_club_id {
        obj.insert("buyer_club_id".into(), Value::from(buyer));
    }
    body
}

/// Serialize a [`CoreReplaceRequest`] into the `PUT /squad/replace` JSON body.
pub fn replace_request_body(req: &CoreReplaceRequest) -> Value {
    let slots: Vec<Value> = req
        .slots
        .iter()
        .map(|s| {
            json!({
                "owned_card_id": s.owned_card_id,
                "slot": s.index,
                "is_captain": s.is_captain,
                "is_on_bench": s.is_on_bench,
            })
        })
        .collect();
    json!({
        "name": req.name,
        "formation": req.formation,
        "slots": slots,
        "client_reported": {
            "client_reported_chemistry": req.client_reported.chemistry,
            "client_reported_rating": req.client_reported.rating,
            "client_reported_star_rating": req.client_reported.star_rating,
        },
        "extension": {
            "namespace": req.ext_namespace,
            "schema_version": req.ext_schema_version,
            "payload": req.ext_payload,
        },
    })
}

/// Parse Core's `GET /squad/ext` response into a [`CoreSquadRead`].
pub fn parse_core_squad_read(v: &Value) -> Result<CoreSquadRead, CoreError> {
    let squad = v
        .get("squad")
        .ok_or_else(|| CoreError::Parse("missing `squad`".into()))?;
    let name = squad
        .get("name")
        .and_then(|x| x.as_str())
        .unwrap_or("")
        .to_string();
    let formation = squad
        .get("formation")
        .and_then(|x| x.as_str())
        .ok_or_else(|| CoreError::Parse("missing squad.formation".into()))?
        .to_string();
    let players = v
        .get("players")
        .and_then(|p| p.as_array())
        .ok_or_else(|| CoreError::Parse("missing `players`".into()))?;
    let slots = players
        .iter()
        .filter_map(|p| {
            Some(CoreSquadSlot {
                owned_card_id: p.get("owned_card_id")?.as_str()?.to_string(),
                index: p.get("position_index")?.as_i64()?,
                is_captain: p
                    .get("is_captain")
                    .and_then(|x| x.as_bool())
                    .unwrap_or(false),
                is_on_bench: p
                    .get("is_on_bench")
                    .and_then(|x| x.as_bool())
                    .unwrap_or(false),
            })
        })
        .collect();
    let ext_v = v
        .get("extension")
        .ok_or_else(|| CoreError::Parse("missing `extension`".into()))?;
    let ext = match ext_v.get("state").and_then(|x| x.as_str()) {
        Some("fresh") => CoreExtState::Fresh {
            schema_version: ext_v
                .get("schema_version")
                .and_then(|x| x.as_i64())
                .unwrap_or(0),
            payload: ext_v
                .get("payload")
                .and_then(|x| x.as_str())
                .unwrap_or("")
                .to_string(),
        },
        Some("stale") => CoreExtState::Stale {
            schema_version: ext_v
                .get("schema_version")
                .and_then(|x| x.as_i64())
                .unwrap_or(0),
            payload: ext_v
                .get("payload")
                .and_then(|x| x.as_str())
                .unwrap_or("")
                .to_string(),
        },
        Some("missing") => CoreExtState::Missing,
        other => {
            return Err(CoreError::Parse(format!(
                "unknown extension state {other:?}"
            )))
        }
    };
    Ok(CoreSquadRead {
        name,
        formation,
        slots,
        ext,
    })
}

/// Parse Core's `/collection` response `{ "collection": [...], "total": n }` into
/// semantic owned items.
pub fn parse_core_page(v: &Value) -> Result<CorePage, CoreError> {
    let arr = v
        .get("collection")
        .and_then(|c| c.as_array())
        .ok_or_else(|| CoreError::Parse("missing `collection` array".into()))?;
    let total = v
        .get("total")
        .and_then(|t| t.as_i64())
        .unwrap_or(arr.len() as i64);
    let items = arr.iter().filter_map(core_item_from_json).collect();
    Ok(CorePage { items, total })
}

fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
    let card = e.get("card")?;
    let attr = |k: &str| card.get(k).and_then(|v| v.as_i64()).unwrap_or(0) as u8;
    let position = e
        .get("effective_position")
        .and_then(|v| v.as_str())
        .or_else(|| card.get("position").and_then(|v| v.as_str()))?
        .to_string();
    let rating = e
        .get("effective_overall")
        .and_then(|v| v.as_i64())
        .or_else(|| card.get("overall").and_then(|v| v.as_i64()))
        .unwrap_or(0) as u8;
    Some(CoreOwnedItem {
        owned_card_id: e.get("owned_card_id")?.as_str()?.to_string(),
        card_id: card.get("id")?.as_str()?.to_string(),
        rating,
        position,
        nation: card.get("nation")?.as_str()?.to_string(),
        league: card.get("league")?.as_str()?.to_string(),
        club: card.get("club")?.as_str()?.to_string(),
        attributes: [
            attr("pace"),
            attr("shooting"),
            attr("passing"),
            attr("dribbling"),
            attr("defending"),
            attr("physical"),
        ],
    })
}

/// Parse Core's `/cards` response `{ "cards": [CardDefinition...], ... }` into the
/// same [`CoreOwnedItem`] shape as owned items (`owned_card_id` empty — a
/// definition is not an instance), for full-universe pool building.
pub fn parse_core_definitions(v: &Value) -> Result<Vec<CoreOwnedItem>, CoreError> {
    let arr = v
        .get("cards")
        .and_then(|c| c.as_array())
        .ok_or_else(|| CoreError::Parse("missing `cards` array".into()))?;
    Ok(arr.iter().filter_map(core_item_from_definition).collect())
}

fn core_item_from_definition(card: &Value) -> Option<CoreOwnedItem> {
    let attr = |k: &str| card.get(k).and_then(|v| v.as_i64()).unwrap_or(0) as u8;
    Some(CoreOwnedItem {
        owned_card_id: String::new(),
        card_id: card.get("id")?.as_str()?.to_string(),
        rating: card.get("overall").and_then(|v| v.as_i64()).unwrap_or(0) as u8,
        position: card.get("position")?.as_str()?.to_string(),
        nation: card.get("nation")?.as_str()?.to_string(),
        league: card.get("league")?.as_str()?.to_string(),
        club: card.get("club")?.as_str()?.to_string(),
        attributes: [
            attr("pace"),
            attr("shooting"),
            attr("passing"),
            attr("dribbling"),
            attr("defending"),
            attr("physical"),
        ],
    })
}

// ───────────────────────────── Item identity resolver ───────────────────────

/// The single production [`ItemIdentityResolver`]: it composes the two distinct
/// FIFA 17 identities from real, persistent sources — no placeholder, no hash,
/// no fabricated id.
///
/// * **Definition identity** (`resourceId`/`assetId`) comes from the
///   [`Fifa17CardCatalog`]: `card_id` → real FIFA asset id. An unmapped
///   definition resolves to `None` → the item is dropped and counted, never
///   faked.
/// * **Instance identity** (`item_id`) comes from the generic
///   [`ExternalIdentityStore`] under the FIFA 17 wire-id policy: the same owned
///   instance always resolves to the same monotonic wire id, it survives
///   restart, and it reverses exactly. Two copies of the same definition share a
///   `resourceId` but get distinct `item_id`s.
///
/// The wire-id namespace is **globally monotonic within `(game, "owned-item")`**,
/// not per-account. Python restarts numbering per save file; Core owned-instance
/// ids are globally-unique UUIDs, so a single monotonic sequence keeps every
/// wire id unique and its reverse lookup unambiguous across all accounts —
/// satisfying the client's only requirement (per-session unique/stable/
/// reversible ids). An account column is therefore unnecessary.
pub struct Fifa17IdentityResolver {
    catalog: Fifa17CardCatalog,
    store: Arc<dyn ExternalIdentityStore>,
}

impl Fifa17IdentityResolver {
    pub fn new(catalog: Fifa17CardCatalog, store: Arc<dyn ExternalIdentityStore>) -> Self {
        Fifa17IdentityResolver { catalog, store }
    }

    /// Reverse an owned-item wire id back to its Core owned-instance id (used by
    /// later item-operation slices). `None` = unknown wire id, never a guess.
    pub fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
        self.store
            .core_for(
                Fifa17WireItemIdPolicy::GAME,
                Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
                wire,
            )
            .unwrap_or(None)
    }

    /// The FIFA `cardsubtypeid` for an owned item's definition (0 if unknown /
    /// a player), from the catalog — used by club-stats family aggregation.
    pub fn subtype_of(&self, item: &CoreOwnedItem) -> i64 {
        self.catalog.subtype_of(&item.card_id)
    }

    /// The observed FIFA `rareflag` for an owned item's definition (0 if unknown),
    /// from the catalog — used by club-stats rare-player counting.
    pub fn rareflag_of(&self, item: &CoreOwnedItem) -> i64 {
        self.catalog
            .lookup(&item.card_id)
            .map(|c| c.rareflag)
            .unwrap_or(0)
    }

    /// Non-minting definition identity from the catalog: `Some((rareflag, kind))`
    /// if the card resolves, else `None`. Used to build the pack pool over ALL
    /// content definitions WITHOUT allocating a wire id per definition (that would
    /// pollute the identity store); a real wire id is minted only when a pack draw
    /// actually mints the card.
    pub fn definition_identity(&self, card_id: &str) -> Option<(i64, ContentKind)> {
        self.catalog.lookup(card_id).map(|c| (c.rareflag, c.kind))
    }
}

impl ItemIdentityResolver for Fifa17IdentityResolver {
    fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity> {
        // Definition identity first: an unmapped card is dropped (never faked).
        let ident = self.catalog.lookup(&item.card_id)?;
        // Instance identity: stable, persistent, reversible wire id.
        let wire = match self.store.resolve_or_allocate(
            Fifa17WireItemIdPolicy::GAME,
            Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
            &item.owned_card_id,
            Fifa17WireItemIdPolicy::owned_item_base_floor(),
        ) {
            Ok(w) => w,
            Err(e) => {
                // Infrastructure failure allocating a wire id: drop this item
                // (freeze-safe) and log — never emit an unstable/fake id.
                eprintln!(
                    "utas-host ERROR identity store alloc failed for {}: {e}",
                    item.owned_card_id
                );
                return None;
            }
        };
        Some(Fifa17Identity {
            // Wire ids live in 1e8..9e8 (policy) — well within u32.
            item_id: wire as u32,
            asset_id: ident.asset_id,
            resource_id: ident.resource_id,
            rareflag: ident.rareflag,
        })
    }

    /// Delegate content classification to the catalog so `/club` excludes
    /// consumable/staff cards (they must never render as 0-rated players). An
    /// unmapped card_id resolves to `Player` (the catalog default) but is already
    /// dropped by `resolve` returning `None`, so it is never emitted anyway.
    fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind {
        self.catalog.kind_of(&item.card_id)
    }
}

/// The same production resolver reverses a wire id to a Core owned-instance id
/// for the squad PUT path — reusing the identity store, so `/club`, `/squad`,
/// and PUT all agree on wire↔owned. Identity ONLY; ownership is authorized
/// separately (a resolvable id is not proof of ownership).
impl SquadWireResolver for Fifa17IdentityResolver {
    fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
        Fifa17IdentityResolver::owned_id_for_wire(self, wire)
    }
}

// ───────────────────────────── /club handler ────────────────────────────────

/// Safe, structured summary of a handled `/club` request (no auth/session/device
/// material — the club query carries none; auth is a header we never log).
#[derive(Debug, Clone)]
pub struct ClubLog {
    pub outcome: &'static str,
    pub filter: String,
    pub total: i64,
    pub emitted: usize,
    pub dropped_no_asset: usize,
    pub offset: Option<i64>,
    pub limit: Option<i64>,
}

/// Dependencies for the Rust `/club` path.
pub struct ClubDeps<'a> {
    pub core: &'a dyn CoreAccess,
    pub entities: &'a Fifa17Entities,
    pub assets: &'a (dyn ItemIdentityResolver + Send + Sync),
    /// Core owned-instance ids that are NOT in the club view — the cards with an
    /// ACTIVE transfer-market listing. In FIFA a listed card has LEFT the club, so
    /// it must not also appear here. Empty = show everything Core owns.
    pub hidden: &'a std::collections::HashSet<String>,
}

/// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate
/// the filtered set locally. Returns `(page, total_specials)`. Pure — the whole
/// point is that "special" pagination is over the filtered set, never Core's
/// unfiltered page (which would drop specials or leak base cards).
fn special_filter_page(
    items: &[Value],
    offset: Option<i64>,
    limit: Option<i64>,
) -> (Vec<Value>, i64) {
    let specials: Vec<&Value> = items
        .iter()
        .filter(|it| {
            it.get("rareflag")
                .and_then(|v| v.as_i64())
                .map(is_special_rareflag)
                .unwrap_or(false)
        })
        .collect();
    let total = specials.len() as i64;
    let off = offset.unwrap_or(0).max(0) as usize;
    let paged: Vec<Value> = match limit {
        Some(l) => specials
            .into_iter()
            .skip(off)
            .take(l.max(0) as usize)
            .cloned()
            .collect(),
        None => specials.into_iter().skip(off).cloned().collect(),
    };
    (paged, total)
}

/// Paginate an already-shaped, already-filtered `/club` item list locally,
/// returning `(page, total_after_filtering)`. Used when the host filters on
/// something Core cannot express (pile membership, rareflag), where Core's own
/// offset/limit would paginate the WRONG set and yield short pages.
fn paginate_items(items: &[Value], offset: Option<i64>, limit: Option<i64>) -> (Vec<Value>, i64) {
    let total = items.len() as i64;
    let off = offset.unwrap_or(0).max(0) as usize;
    let paged: Vec<Value> = match limit {
        Some(l) => items
            .iter()
            .skip(off)
            .take(l.max(0) as usize)
            .cloned()
            .collect(),
        None => items.iter().skip(off).cloned().collect(),
    };
    (paged, total)
}

/// Handle `GET …/club?…` end to end: parse → map ids to names → Core query →
/// shape to the FIFA `{itemData:[…]}` envelope. Always returns HTTP 200 with a
/// JSON body (UTAS must never 401/403; an empty result is the safe degrade).
pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) {
    let raw = parse_club_query(query);
    let core_q = match map_to_core(&raw, deps.entities) {
        Ok(c) => c,
        Err(e) => {
            // Unknown FIFA id — never a raw-id passthrough, never a guess.
            return (
                json_response(&json!({ "itemData": [] })),
                ClubLog {
                    outcome: "unknown_id",
                    filter: describe_map_error(&e),
                    total: 0,
                    emitted: 0,
                    dropped_no_asset: 0,
                    offset: raw.start.map(|s| s as i64),
                    limit: raw.count.map(|c| c as i64),
                },
            );
        }
    };
    let pairs = core_q.to_query_pairs();
    let filter = summarize(&pairs);
    let (offset, limit) = (core_q.offset, core_q.limit);

    // Host-side filters Core cannot express:
    //   * "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core.
    //   * listed-card exclusion: transfer-market listings are host-owned state.
    // Either way Core must NOT paginate — it would paginate the unfiltered set
    // and return short pages. So fetch everything matching the OTHER filters,
    // exclude/shape locally, then paginate the filtered set here. When neither
    // applies (the common case) the fast Core-paginated path below is unchanged.
    if core_q.special || !deps.hidden.is_empty() {
        // Log which host-side filter forced local pagination.
        let local_desc = match (core_q.special, deps.hidden.len()) {
            (true, 0) => format!("{filter},rare=SP"),
            (true, n) => format!("{filter},rare=SP,hidden={n}"),
            (false, n) => format!("{filter},hidden={n}"),
        };
        let mut base = core_q.clone();
        base.offset = None;
        base.limit = None;
        return match deps.core.query_owned(&base.to_query_pairs()) {
            Ok(page) => {
                // Exclude hidden instances BEFORE shaping: a card on the transfer
                // list is not in the club, so it must not consume a page slot.
                let visible: Vec<CoreOwnedItem> = page
                    .items
                    .into_iter()
                    .filter(|it| !deps.hidden.contains(&it.owned_card_id))
                    .collect();
                let (body, stats) = shape_club_response(&visible, deps.entities, deps.assets);
                let all = body
                    .get("itemData")
                    .and_then(|v| v.as_array())
                    .cloned()
                    .unwrap_or_default();
                let (paged, total) = if core_q.special {
                    special_filter_page(&all, offset, limit)
                } else {
                    paginate_items(&all, offset, limit)
                };
                let emitted = paged.len();
                (
                    json_response(&json!({ "itemData": paged })),
                    ClubLog {
                        outcome: "ok",
                        filter: local_desc,
                        total,
                        emitted,
                        dropped_no_asset: stats.dropped_no_asset,
                        offset,
                        limit,
                    },
                )
            }
            Err(e) => {
                eprintln!("utas-host ERROR /club (local-filter) core query failed: {e}");
                (
                    json_response(&json!({ "itemData": [] })),
                    ClubLog {
                        outcome: "core_error",
                        filter: local_desc,
                        total: 0,
                        emitted: 0,
                        dropped_no_asset: 0,
                        offset,
                        limit,
                    },
                )
            }
        };
    }
    match deps.core.query_owned(&pairs) {
        Ok(page) => {
            let (body, stats): (Value, ShapeStats) =
                shape_club_response(&page.items, deps.entities, deps.assets);
            (
                json_response(&body),
                ClubLog {
                    outcome: "ok",
                    filter,
                    total: page.total,
                    emitted: stats.emitted,
                    dropped_no_asset: stats.dropped_no_asset,
                    offset,
                    limit,
                },
            )
        }
        Err(e) => {
            // Degrade to a valid empty page; DO NOT fall back to Python.
            eprintln!("utas-host ERROR /club core query failed: {e}");
            (
                json_response(&json!({ "itemData": [] })),
                ClubLog {
                    outcome: "core_error",
                    filter,
                    total: 0,
                    emitted: 0,
                    dropped_no_asset: 0,
                    offset,
                    limit,
                },
            )
        }
    }
}

fn describe_map_error(e: &MapError) -> String {
    match e {
        MapError::UnknownLeague(id) => format!("unknown_league={id}"),
        MapError::UnknownNation(id) => format!("unknown_nation={id}"),
        MapError::UnknownTeam(id) => format!("unknown_team={id}"),
    }
}

fn summarize(pairs: &[(&str, String)]) -> String {
    pairs
        .iter()
        .map(|(k, v)| format!("{k}={v}"))
        .collect::<Vec<_>>()
        .join(",")
}

// ───────────────────────────── Squad handlers ───────────────────────────────

/// FIFA wire id of the single active squad (`…/squad/0`).
pub const ACTIVE_SQUAD_WIRE_ID: i64 = 0;

/// Dependencies for the Rust squad paths. `resolver` is the SAME production
/// identity resolver `/club` uses (forward shape + reverse wire→owned), so every
/// route agrees on wire↔owned identity.
pub struct SquadDeps<'a> {
    pub core: &'a dyn CoreAccess,
    pub resolver: &'a Fifa17IdentityResolver,
    pub entities: &'a Fifa17Entities,
}

/// Secret-free structured log line for a handled squad request.
#[derive(Debug, Clone)]
pub struct SquadLog {
    pub outcome: &'static str,
    pub detail: String,
}

/// The active squad projected from Core, with the freshness policy applied.
enum HostProjection {
    /// Fresh: the projected FIFA squad object (before any endpoint envelope).
    Squad(Value),
    /// Stored extension is stale vs the canonical squad — NEVER applied.
    Stale,
    /// No extension stored — nothing fabricated.
    Missing,
    /// Core unreachable / response unreadable / projection failed.
    Error(String),
}

/// Assemble the projection input from Core in a BOUNDED number of calls — one
/// `read_squad_ext` + one batch `all_owned`, never per slot — then project. The
/// Fresh/Stale/Missing policy is decided HERE, not buried in a default.
fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
    let read = match deps.core.read_squad_ext(EXT_NAMESPACE) {
        Ok(r) => r,
        Err(e) => return HostProjection::Error(e.to_string()),
    };
    let ext = match &read.ext {
        CoreExtState::Fresh {
            schema_version,
            payload,
        } => {
            match Fifa17SquadExtensionV1::from_payload(*schema_version, payload) {
                Ok(e) => e,
                // Fresh but the payload does not parse as our schema: corruption,
                // never coerced into a fabricated squad.
                Err(e) => return HostProjection::Error(format!("fresh extension unreadable: {e}")),
            }
        }
        CoreExtState::Stale { .. } => return HostProjection::Stale,
        CoreExtState::Missing => return HostProjection::Missing,
    };
    let owned = match deps.core.all_owned() {
        Ok(v) => v,
        Err(e) => return HostProjection::Error(e.to_string()),
    };
    let owned_by_id: std::collections::HashMap<String, CoreOwnedItem> = owned
        .into_iter()
        .map(|i| (i.owned_card_id.clone(), i))
        .collect();
    let slots: Vec<ProjectionSlot> = read
        .slots
        .iter()
        .map(|s| ProjectionSlot {
            owned_card_id: s.owned_card_id.clone(),
            index: s.index,
            is_captain: s.is_captain,
            is_on_bench: s.is_on_bench,
        })
        .collect();
    // The ownership-backed manager assignment (Core `squad_managers`). A fetch
    // error or absent endpoint yields no manager — non-fatal, a squad renders
    // without one, never fabricated.
    let manager = deps
        .core
        .get_squad_manager()
        .ok()
        .flatten()
        .and_then(|id| owned_by_id.get(&id).cloned());
    let input = SquadProjectionInput {
        fifa_squad_id: ACTIVE_SQUAD_WIRE_ID,
        name: read.name,
        formation: read.formation,
        slots,
        ext: SquadExtInput::Fresh(ext),
        owned: &owned_by_id,
        manager,
    };
    match project_squad(&input, deps.resolver, deps.entities) {
        Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
        Ok(SquadProjection::Stale) => HostProjection::Stale,
        Ok(SquadProjection::Missing) => HostProjection::Missing,
        Err(e) => HostProjection::Error(e.to_string()),
    }
}

/// A small JSON error body (UTAS mutations that cannot be honoured fail loudly —
/// they are NEVER retried against Python, which would risk a double mutation).
fn error_response(status: u16, code: &str) -> WireResponse {
    WireResponse {
        status,
        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
        body: format!("{{\"error\":\"{code}\"}}").into_bytes(),
        transport: ResponseTransport::Normal,
    }
}

/// `PUT …/squad/<n>` — parse the full replacement, reverse-resolve every wire id,
/// AUTHORIZE every resolved item against the active club, then commit the
/// canonical squad + FIFA extension to Core in one transaction. On any failure
/// it returns an error and NEVER falls back to Python (no double mutation).
pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
    let put = match parse_squad_put(body) {
        Ok(p) => p,
        Err(e) => {
            return (
                error_response(400, "parse_error"),
                SquadLog {
                    outcome: "parse_error",
                    detail: e.to_string(),
                },
            )
        }
    };
    // Reverse-resolve wire→owned and shape canonical + extension. Refuses on an
    // unresolved wire id or the same owned item placed twice.
    let build = match build_squad_write(&put, deps.resolver) {
        Ok(b) => b,
        Err(SquadBuildError::UnresolvedWireIds(ids)) => {
            return (
                error_response(400, "unresolved_wire_ids"),
                SquadLog {
                    outcome: "unresolved_wire_ids",
                    detail: format!("{ids:?}"),
                },
            )
        }
        Err(SquadBuildError::DuplicateOwnedItem(id)) => {
            return (
                error_response(400, "duplicate_owned_item"),
                SquadLog {
                    outcome: "duplicate_owned_item",
                    detail: id,
                },
            )
        }
    };
    // AUTHORIZATION — identity resolution is NOT authorization. Every resolved
    // owned item MUST belong to the active club; a globally-valid wire id that
    // belongs to another profile is rejected BEFORE any canonical mutation.
    let owned_set: std::collections::HashSet<String> = match deps.core.all_owned() {
        Ok(v) => v.into_iter().map(|i| i.owned_card_id).collect(),
        Err(e) => {
            return (
                error_response(502, "core_error"),
                SquadLog {
                    outcome: "core_error",
                    detail: e.to_string(),
                },
            )
        }
    };
    for slot in &build.canonical.slots {
        if !owned_set.contains(&slot.owned_card_id) {
            return (
                error_response(403, "not_owned"),
                SquadLog {
                    outcome: "unauthorized_item",
                    detail: slot.owned_card_id.clone(),
                },
            );
        }
    }
    // The manager is a resolved, owned assignment too: authorize it like a slot.
    if let Some(mgr) = &build.canonical.manager_owned_card_id {
        if !owned_set.contains(mgr) {
            return (
                error_response(403, "not_owned"),
                SquadLog {
                    outcome: "unauthorized_manager",
                    detail: mgr.clone(),
                },
            );
        }
    }
    // Commit canonical + extension atomically. No Python fallback on failure.
    let req = CoreReplaceRequest {
        name: build.canonical.name.clone(),
        formation: build.canonical.formation.clone(),
        slots: build
            .canonical
            .slots
            .iter()
            .map(|s| CoreSquadSlot {
                owned_card_id: s.owned_card_id.clone(),
                index: s.index,
                is_captain: s.is_captain,
                is_on_bench: s.is_on_bench,
            })
            .collect(),
        client_reported: CoreClientEval {
            chemistry: build.extension.client_reported.chemistry,
            rating: build.extension.client_reported.rating,
            star_rating: build.extension.client_reported.star_rating,
        },
        ext_namespace: EXT_NAMESPACE.to_string(),
        ext_schema_version: EXT_SCHEMA_VERSION,
        ext_payload: build.extension.to_payload(),
    };
    if let Err(e) = deps.core.replace_squad(&req) {
        return (
            error_response(502, "core_error"),
            SquadLog {
                outcome: "core_error",
                detail: e.to_string(),
            },
        );
    }
    // Persist the ownership-backed manager assignment (migration 0023). It was
    // authorized above and Core re-validates club ownership; fail loudly on a
    // transport error rather than silently dropping the manager.
    if let Err(e) = deps
        .core
        .set_squad_manager(build.canonical.manager_owned_card_id.as_deref())
    {
        return (
            error_response(502, "core_error"),
            SquadLog {
                outcome: "manager_error",
                detail: e.to_string(),
            },
        );
    }
    (
        json_response(&save_ack(put.id)),
        SquadLog {
            outcome: "ok",
            detail: String::new(),
        },
    )
}

/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are
/// integrity failures for the migrated dev profile: logged prominently, degraded
/// to an empty list, NEVER served from Python and NEVER projected from stale ext.
pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
    match project_active_squad(deps) {
        HostProjection::Squad(v) => (
            json_response(&squad_list(&v)),
            SquadLog {
                outcome: "ok",
                detail: String::new(),
            },
        ),
        HostProjection::Stale => (
            json_response(&json!({ "squad": [] })),
            SquadLog {
                outcome: "stale_integrity",
                detail: "stale extension not applied".into(),
            },
        ),
        HostProjection::Missing => (
            json_response(&json!({ "squad": [] })),
            SquadLog {
                outcome: "missing_integrity",
                detail: "no extension stored".into(),
            },
        ),
        HostProjection::Error(e) => (
            json_response(&json!({ "squad": [] })),
            SquadLog {
                outcome: "core_error",
                detail: e,
            },
        ),
    }
}

/// `GET …/squad/active` — the active squad projected from Core, returned as the
/// top-level squad object (byte-identical to what `userMassInfo.squad` embeds).
/// Never served from Python and NEVER projected from a stale extension; on a
/// stale/missing extension or a Core error it degrades to an honest empty squad
/// (never 401/403, never a Python fallback that could mask split authority).
pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) {
    match project_active_squad(deps) {
        HostProjection::Squad(v) => (
            json_response(&user_mass_info_squad(v, persona_id)),
            SquadLog {
                outcome: "ok",
                detail: String::new(),
            },
        ),
        HostProjection::Stale => (
            json_response(&empty_squad_overlay(persona_id)),
            SquadLog {
                outcome: "stale_integrity",
                detail: "stale extension not applied".into(),
            },
        ),
        HostProjection::Missing => (
            json_response(&empty_squad_overlay(persona_id)),
            SquadLog {
                outcome: "missing_integrity",
                detail: "no extension stored".into(),
            },
        ),
        HostProjection::Error(e) => (
            json_response(&empty_squad_overlay(persona_id)),
            SquadLog {
                outcome: "core_error",
                detail: e,
            },
        ),
    }
}

/// An explicit empty active squad used only when Rust squad authority cannot
/// produce a Fresh projection during a userMassInfo overlay. It is NOT Python's
/// squad (that would reintroduce split authority) and NOT fabricated extension
/// state — it is an honest "no squad available", surfaced with an ERROR log.
fn empty_squad_overlay(persona_id: i64) -> Value {
    json!({
        "id": ACTIVE_SQUAD_WIRE_ID,
        "personaId": persona_id,
        "changed": 0,
        "actives": [],
        "players": [],
    })
}

/// Replace `resp`'s body with `body`, fixing framing headers (Content-Length,
/// Content-Type; drops any stale length/transfer-encoding).
fn set_json_body(resp: &mut WireResponse, body: Vec<u8>) {
    resp.headers.retain(|(k, _)| {
        !k.eq_ignore_ascii_case("content-length")
            && !k.eq_ignore_ascii_case("content-type")
            && !k.eq_ignore_ascii_case("transfer-encoding")
    });
    resp.headers
        .push(("Content-Type".to_string(), "application/json".to_string()));
    resp.headers
        .push(("Content-Length".to_string(), body.len().to_string()));
    resp.body = body;
}

/// `GET …/userMassInfo` — proxy the request to Python verbatim, then overlay ONLY
/// `.squad` with the Rust/Core projection. Every unrelated field (`userInfo`,
/// `settings`, `userData`, `pileSizeClientData`, …) is preserved exactly.
pub fn handle_user_mass_info(
    method: &str,
    target: &str,
    headers: &[(String, String)],
    body: &[u8],
    deps: &SquadDeps<'_>,
    pass: &PassClient,
) -> (WireResponse, SquadLog) {
    let mut resp = match pass.forward(method, target, headers, body) {
        Ok(r) => r,
        Err(e) => {
            return (
                error_response(502, "upstream_unavailable"),
                SquadLog {
                    outcome: "python_unreachable",
                    detail: e.to_string(),
                },
            )
        }
    };
    // Only a successful JSON object carrying `.squad` is overlaid; anything else
    // is returned verbatim (we never invent a squad into an unrelated response).
    if !(200..300).contains(&resp.status) {
        return (
            resp,
            SquadLog {
                outcome: "python_non_2xx_passthrough",
                detail: String::new(),
            },
        );
    }
    let mut root: Value = match serde_json::from_slice::<Value>(&resp.body) {
        Ok(v) if v.is_object() => v,
        _ => {
            return (
                resp,
                SquadLog {
                    outcome: "python_body_unusable_passthrough",
                    detail: String::new(),
                },
            )
        }
    };
    if root.get("squad").is_none() {
        return (
            resp,
            SquadLog {
                outcome: "python_no_squad_passthrough",
                detail: String::new(),
            },
        );
    }
    // Preserve the client's persona from Python's own response.
    let persona = root["squad"]
        .get("personaId")
        .and_then(|x| x.as_i64())
        .or_else(|| {
            root.get("userInfo")
                .and_then(|u| u.get("personaId"))
                .and_then(|x| x.as_i64())
        })
        .unwrap_or(0);
    let (squad_val, log) = match project_active_squad(deps) {
        HostProjection::Squad(v) => (
            user_mass_info_squad(v, persona),
            SquadLog {
                outcome: "ok",
                detail: String::new(),
            },
        ),
        HostProjection::Stale => (
            empty_squad_overlay(persona),
            SquadLog {
                outcome: "stale_integrity",
                detail: "stale extension not applied".into(),
            },
        ),
        HostProjection::Missing => (
            empty_squad_overlay(persona),
            SquadLog {
                outcome: "missing_integrity",
                detail: "no extension stored".into(),
            },
        ),
        HostProjection::Error(e) => (
            empty_squad_overlay(persona),
            SquadLog {
                outcome: "core_error",
                detail: e,
            },
        ),
    };
    root["squad"] = squad_val;
    let new_body = serde_json::to_vec(&root).unwrap_or_else(|_| resp.body.clone());
    set_json_body(&mut resp, new_body);
    (resp, log)
}

// ─────────────── Rust economy handlers (Core-backed authority) ───────────────
//
// These implement the FIFA17 economy routes against Core economy authority.
// They are Core-backed and fail-closed: a Core error yields a controlled FIFA-
// compatible response and NEVER a Python fallback (which would be a second
// writer). They are wired into `classify` only as one coherent barrier once the
// whole coins cluster (writers + readers) flips together and Core is seeded from
// the profile — a partial flip would desync the client's coin counter.

/// Build the `GET /user/credits` body — byte-shape-identical to the Python
/// oracle (`credits` + `currencies[].funds/finalFunds`, optional
/// `unopenedPacks.recoveredPacks`). The hub coin counter binds to
/// `currencies[0].funds`, not `credits`.
pub fn build_credits_body(coins: i64, unopened_count: usize) -> Value {
    let mut body = json!({
        "credits": coins,
        "currencies": [
            {"name": "coins", "funds": coins, "finalFunds": coins},
            {"name": "points", "funds": 0, "finalFunds": 0},
        ],
    });
    if unopened_count > 0 {
        body.as_object_mut().unwrap().insert(
            "unopenedPacks".into(),
            json!({ "preOrderPacks": 0, "recoveredPacks": unopened_count }),
        );
    }
    body
}

/// `GET /user/credits` from Core authority: coins = Core balance, recoveredPacks
/// = Core unconsumed entitlement count. Fail-closed on any Core error (503, no
/// Python fallback, no fabricated balance).
pub fn handle_credits(econ: &dyn CoreEconomy) -> WireResponse {
    match (econ.balance(), econ.entitlements()) {
        (Ok(coins), Ok(ents)) => json_response(&build_credits_body(coins, ents.len())),
        _ => error_response(503, "core_unavailable"),
    }
}

/// Map Core entitlements to FIFA 17 unopened pack ids. `definition_id` is either
/// a numeric owned-only pack id (imported entitlements) or a symbolic reward-pack
/// name granted by Core's reward services; both resolve via
/// [`owned_pack_id_for_definition`]. Unresolvable entitlements are skipped, never
/// faked.
fn entitlement_pack_ids(ents: &[EconomyEntitlement]) -> Vec<u64> {
    ents.iter()
        .filter_map(|e| owned_pack_id_for_definition(&e.definition_id))
        .collect()
}

/// `GET /store/purchasegroup` fully generated in Rust: normal catalogue packs +
/// Core-owned unopened packs + the empty-My-Packs shim per `StoreMode`. No
/// Python body dependency. Fail-closed on Core error (503, never Python).
pub fn handle_purchasegroup(econ: &dyn CoreEconomy, mode: StoreMode) -> WireResponse {
    match econ.entitlements() {
        Ok(ents) => {
            let ids = entitlement_pack_ids(&ents);
            json_response(&build_purchasegroup(&ids, mode))
        }
        Err(_) => error_response(503, "core_unavailable"),
    }
}

/// Overlay the authoritative Core economy onto a `userMassInfo` body in place:
/// set `userInfo.currencies[coins].funds/finalFunds` and
/// `userInfo.unopenedPacks.recoveredPacks`. Pure; every other field is
/// preserved. Mirrors the oracle shape (coins element by `name == "coins"`;
/// `unopenedPacks` only present when count > 0). Returns true if applied.
pub fn overlay_massinfo_economy(root: &mut Value, coins: i64, unopened_count: usize) -> bool {
    let Some(user_info) = root.get_mut("userInfo").and_then(Value::as_object_mut) else {
        return false;
    };
    if let Some(currencies) = user_info
        .get_mut("currencies")
        .and_then(Value::as_array_mut)
    {
        for cur in currencies.iter_mut() {
            if cur.get("name").and_then(Value::as_str) == Some("coins") {
                if let Some(obj) = cur.as_object_mut() {
                    obj.insert("funds".into(), json!(coins));
                    obj.insert("finalFunds".into(), json!(coins));
                }
            }
        }
    }
    if unopened_count > 0 {
        user_info.insert(
            "unopenedPacks".into(),
            json!({ "preOrderPacks": 0, "recoveredPacks": unopened_count }),
        );
    } else {
        user_info.remove("unopenedPacks");
    }
    true
}

/// Derive the per-match economic identity from a persona + the match-end body.
/// This is the key for Core's durable exactly-once guard — NOT a FIFA HTTP
/// receipt id. When the client sends a non-zero `matchReportId` it keys on that;
/// otherwise (the observed live path reports `0`) it keys on a stable fingerprint
/// of the body so an identical network retry dedupes in Core, while distinct
/// decided matches (whose stat arrays differ) get distinct identities.
fn match_identity(persona: i64, end: &match_wire::MatchEnd, body: &[u8]) -> String {
    if end.match_report_id != 0 {
        return format!("{persona}:report:{}", end.match_report_id);
    }
    use std::hash::{Hash, Hasher};
    let mut h = std::collections::hash_map::DefaultHasher::new();
    body.hash(&mut h);
    format!("{persona}:fp:{:016x}", h.finish())
}

/// Handle the coin-crediting match-end call: parse the FIFA wire, then apply the
/// match to OpenFUT Core's authoritative, atomic, exactly-once `complete_match`
/// transaction and render Core's authoritative coin numbers back onto the wire.
/// FAIL-CLOSED: a malformed body is a 400 and ANY Core error is a 503 — the
/// economy is never applied by Python (a second writer would break exactly-once).
pub fn handle_match_end(econ: &dyn CoreEconomy, persona: i64, body: &[u8]) -> WireResponse {
    let Some(end) = match_wire::parse_match_end(body) else {
        return error_response(400, "bad_match_end");
    };
    let identity = match_identity(persona, &end, body);
    let completion = CoreMatchCompletion {
        match_identity: &identity,
        result: end.result.core_token(),
        squad_id: "",
        opponent_name: "",
        goals_for: end.goals_for,
        goals_against: end.goals_against,
        mode: "seasons",
    };
    match econ.complete_match(&completion) {
        Ok(receipt) => json_response(&match_wire::reward_response(
            receipt.coins_balance,
            receipt.coins_awarded,
        )),
        Err(_) => error_response(503, "core_unavailable"),
    }
}

// ───────────────────────────── HTTP wire types ──────────────────────────────

/// A response ready to write: status, headers, body, and an internal
/// staging-only transport directive.
#[derive(Debug, Clone)]
pub struct WireResponse {
    pub status: u16,
    pub headers: Vec<(String, String)>,
    pub body: Vec<u8>,
    transport: ResponseTransport,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResponseTransport {
    Normal,
    Drop,
    Malformed,
    Delay { millis: u64 },
}

impl From<SbcPostCommitFault> for ResponseTransport {
    fn from(value: SbcPostCommitFault) -> Self {
        match value {
            SbcPostCommitFault::Off => Self::Normal,
            SbcPostCommitFault::Drop => Self::Drop,
            SbcPostCommitFault::Malformed => Self::Malformed,
            SbcPostCommitFault::Delay { millis } => Self::Delay { millis },
        }
    }
}

fn json_response(body: &Value) -> WireResponse {
    let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
    WireResponse {
        status: 200,
        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
        body: bytes,
        transport: ResponseTransport::Normal,
    }
}

fn is_hop_by_hop(name: &str) -> bool {
    matches!(
        name.to_ascii_lowercase().as_str(),
        "connection"
            | "keep-alive"
            | "transfer-encoding"
            | "content-length"
            | "host"
            | "proxy-connection"
            | "te"
            | "trailer"
            | "upgrade"
    )
}

// ───────────────────────────── Python passthrough ───────────────────────────

/// Verbatim reverse proxy to the Python UTAS oracle. Preserves method, full
/// target (path + query), end-to-end headers, and body; returns the upstream's
/// status/headers/body faithfully.
pub struct PassClient {
    client: reqwest::blocking::Client,
    upstream: String,
}

impl PassClient {
    pub fn new(upstream: impl Into<String>) -> Self {
        PassClient {
            client: reqwest::blocking::Client::new(),
            upstream: upstream.into().trim_end_matches('/').to_string(),
        }
    }

    pub fn forward(
        &self,
        method: &str,
        target: &str,
        headers: &[(String, String)],
        body: &[u8],
    ) -> Result<WireResponse, CoreError> {
        let url = format!("{}{}", self.upstream, target);
        let m = reqwest::Method::from_bytes(method.as_bytes())
            .map_err(|e| CoreError::Http(format!("bad method: {e}")))?;
        let mut req = self.client.request(m, &url);
        for (k, v) in headers {
            if !is_hop_by_hop(k) {
                req = req.header(k, v);
            }
        }
        if !body.is_empty() {
            req = req.body(body.to_vec());
        }
        let resp = req.send().map_err(|e| CoreError::Http(e.to_string()))?;
        let status = resp.status().as_u16();
        let mut out = Vec::new();
        for (k, v) in resp.headers() {
            if !is_hop_by_hop(k.as_str()) {
                if let Ok(s) = v.to_str() {
                    out.push((k.to_string(), s.to_string()));
                }
            }
        }
        let bytes = resp
            .bytes()
            .map_err(|e| CoreError::Http(e.to_string()))?
            .to_vec();
        Ok(WireResponse {
            status,
            headers: out,
            body: bytes,
            transport: ResponseTransport::Normal,
        })
    }
}

// ───────────────────────────── Server ───────────────────────────────────────

/// The durable services the FIFA17 economy handlers need, wired into [`Server`]
/// ONCE at construction (never per request). All are process-lifetime `Arc`s:
/// the Core economy transport, the two host-owned durable SQLite stores (listing
/// and pile), the shared Tokio runtime bridge, and the pack-content candidate
/// pool (the resolvable FIFA∩Core card universe).
#[derive(Clone)]
pub struct EconomyServices {
    pub econ: Arc<dyn CoreEconomy>,
    pub market: Arc<crate::market_store::MarketStore>,
    pub piles: Arc<crate::pile_store::PileStore>,
    pub bridge: Arc<crate::async_bridge::AsyncBridge>,
    /// The resolvable FIFA∩Core card universe a pack can award (empty → the Store
    /// fail-closes: it draws nothing and debits nothing).
    pub pool: Arc<Vec<GeneratedCandidate>>,
    /// STAGING-ONLY sold-row experiment. `SoldExperiment::OFF` in production, where
    /// it changes nothing.
    pub sold_experiment: crate::sold_experiment::SoldExperiment,
}

/// Build the pack-content candidate pool. Prefers the FULL card universe (Core
/// content, `GET /cards` via [`CoreAccess::all_definitions`]) so a pack can award
/// any card in the game, not only cards the profile already owns; falls back to
/// owned inventory when content enumeration is unavailable (older Core / tests).
/// Each candidate resolves in the FIFA catalog (unmapped or non-player cards are
/// dropped, never faked); `gold` = rating ≥ 75, `special` = catalog `rareflag > 1`.
/// An empty pool (no content, or Core unreachable) is fail-closed by construction:
/// the generator returns no cards, so the Store neither mints nor debits.
pub fn build_content_pool(
    core: &dyn CoreAccess,
    resolver: &Fifa17IdentityResolver,
) -> Vec<GeneratedCandidate> {
    // Full universe first; owned inventory only as a degrade path.
    let items = match core.all_definitions() {
        Ok(v) if !v.is_empty() => v,
        _ => match core.all_owned() {
            Ok(v) => v,
            Err(_) => return Vec::new(),
        },
    };
    let mut seen = std::collections::HashSet::new();
    let mut pool = Vec::new();
    for item in &items {
        if !seen.insert(item.card_id.clone()) {
            continue;
        }
        // Non-minting catalog lookup: drop unmapped cards and non-player content
        // (consumables/staff never enter the player-card pack pool).
        let Some((rareflag, kind)) = resolver.definition_identity(&item.card_id) else {
            continue;
        };
        if kind != ContentKind::Player {
            continue;
        }
        pool.push(GeneratedCandidate {
            card_id: item.card_id.clone(),
            rating: item.rating,
            position: item.position.clone(),
            nation: item.nation.clone(),
            league: item.league.clone(),
            club: item.club.clone(),
            attributes: item.attributes,
            gold: item.rating >= 75,
            special: rareflag > 1,
        });
    }
    pool
}

/// Production [`crate::economy_store::PurchasedPileSink`]: records a minted item
/// into the durable pile store's "purchased" pile via the runtime bridge, from
/// the synchronous dispatch thread. A pile-write failure is logged, never fatal
/// to the mint (Core already committed the item; the reveal is presentation
/// only, and a missing pile row just omits it from the reveal screen).
struct BridgedPurchasedSink {
    bridge: Arc<crate::async_bridge::AsyncBridge>,
    piles: Arc<crate::pile_store::PileStore>,
}

impl crate::economy_store::PurchasedPileSink for BridgedPurchasedSink {
    fn record_purchased(&self, core_id: &str) {
        let piles = self.piles.clone();
        let id = core_id.to_string();
        if let Err(e) = self
            .bridge
            .block_on(async move { piles.set(&id, "purchased").await })
        {
            eprintln!("utas-host WARN purchased-pile record {core_id} failed: {e}");
        }
    }
}

/// The migration host. Cheap to clone (all shared state is `Arc`).
#[derive(Clone)]
pub struct Server {
    core: Arc<dyn CoreAccess>,
    entities: Arc<Fifa17Entities>,
    /// The single production identity resolver, shared by `/club`, `/squad/*`
    /// and the userMassInfo overlay — one wire↔owned identity everywhere.
    resolver: Arc<Fifa17IdentityResolver>,
    pass: Arc<PassClient>,
    /// The launcher-selected FIFA persona id (injected via `OPENFUT_PERSONA_ID`),
    /// used to stamp `personaId` on the Core-backed `GET /squad/active` object.
    /// Never baked in — it must match the persona LSX/Blaze/POW/UTAS agree on.
    persona_id: i64,
    /// Shared FIFA17 account identity. Rust owns club rename and every Rust
    /// user/account response reads the same durable name/abbreviation.
    account: Arc<AccountStore>,
    /// Per-login FIFA session/capability authority (empty-My-Packs topology).
    /// The economy (coins, unopened packs, BUY) stays Python's; this owns only
    /// session/capability state — Rust reads Python's live body, never writes it.
    sessions: Arc<Mutex<SessionStore>>,
    /// Monotonic clock origin for the session/pending TTLs.
    start: Instant,
    /// FIFA17 economy authority services (Core transport + durable listing/pile
    /// stores + runtime bridge + content pool). `None` until wired via
    /// [`Server::with_economy`]; the economy dispatch is inert without it, and
    /// `handle_with_ip` does not consult it until the classifier barrier.
    economy: Option<Arc<EconomyServices>>,
    /// Durable per-user client-data blob store (`clientdata`/`userHubData`).
    /// [`Server::new`] gives each instance an ephemeral temp-file store;
    /// [`Server::from_config`] wires the configured durable path.
    clientdata: Arc<ClientDataStore>,
    /// Serializes host-owned pile/listing transitions with SBC eligibility checks.
    /// Core supplies the database transaction; this gate closes the cross-store race
    /// within one host process.
    economy_gate: Arc<Mutex<()>>,
    /// Staging-only simulation of losing the successful SBC submit receipt.
    sbc_post_commit_fault: SbcPostCommitFault,
}

impl Server {
    /// Assemble from injected parts (used by `from_config` and tests).
    pub fn new(
        core: Arc<dyn CoreAccess>,
        entities: Arc<Fifa17Entities>,
        resolver: Arc<Fifa17IdentityResolver>,
        pass: Arc<PassClient>,
        persona_id: i64,
    ) -> Self {
        Server {
            core,
            entities,
            resolver,
            pass,
            persona_id,
            account: Arc::new(AccountStore::open(ephemeral_account_path())),
            sessions: Arc::new(Mutex::new(SessionStore::new())),
            start: Instant::now(),
            economy: None,
            clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
            economy_gate: Arc::new(Mutex::new(())),
            sbc_post_commit_fault: SbcPostCommitFault::Off,
        }
    }

    /// Build from config: load entity tables + the FIFA 17 identity catalog, open
    /// the persistent identity store, and wire the Core client + Python
    /// passthrough. Fails clearly if a required production identity source cannot
    /// be loaded — there is no placeholder fallback.
    pub fn from_config(cfg: &HostConfig) -> Result<Self, String> {
        let entities = Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir))
            .map_err(|e| format!("loading entity tables from {}: {e}", cfg.tables_dir))?;
        let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
            .map_err(|e| format!("loading card-definition catalog {}: {e}", cfg.catalog_path))?;
        let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path)
            .map_err(|e| format!("opening identity store {}: {e}", cfg.identity_store_path))?;
        let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
        // One HTTP client, shared as both the read (`CoreAccess`) and the economy
        // (`CoreEconomy`) transport — the same Core, one connection policy.
        let client = Arc::new(HttpCoreClient::new(
            cfg.core_url.clone(),
            Fifa17WireItemIdPolicy::GAME,
        ));
        let core: Arc<dyn CoreAccess> = client.clone();
        let econ: Arc<dyn CoreEconomy> = client;
        let entities = Arc::new(entities);

        // Economy authority services: one runtime bridge + the two durable
        // host-owned SQLite stores (opened here, at host lifetime, NEVER per
        // request) + the content pool. A store that cannot open is a hard startup
        // failure — NEVER a silent omission or a Python economy fallback.
        let bridge = Arc::new(
            crate::async_bridge::AsyncBridge::new()
                .map_err(|e| format!("building economy runtime bridge: {e}"))?,
        );
        let market_path = cfg.market_db_path.clone();
        let market = Arc::new(
            bridge
                .block_on(async move { crate::market_store::MarketStore::open(&market_path).await })
                .map_err(|e| format!("opening market store {}: {e}", cfg.market_db_path))?,
        );
        let pile_path = cfg.pile_db_path.clone();
        let piles = Arc::new(
            bridge
                .block_on(async move { crate::pile_store::PileStore::open(&pile_path).await })
                .map_err(|e| format!("opening pile store {}: {e}", cfg.pile_db_path))?,
        );
        // Content pool from Core's current inventory (empty ⇒ Store fails closed,
        // never mints/debits — an honest degrade if Core is not yet seeded).
        let pool = Arc::new(build_content_pool(core.as_ref(), resolver.as_ref()));
        // Read once at startup and logged, so a staging capture can never be
        // mistaken for production output.
        let sold_experiment = crate::sold_experiment::SoldExperiment::from_env();
        eprintln!("utas-host {}", sold_experiment.banner());
        let economy = Arc::new(EconomyServices {
            econ,
            market,
            piles,
            bridge,
            pool,
            sold_experiment,
        });

        let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
        let account = Arc::new(AccountStore::open(cfg.account_path.clone()));
        eprintln!(
            "utas-host sbc_post_commit_fault={:?}",
            cfg.sbc_post_commit_fault
        );
        Ok(Server::new(
            core,
            entities,
            resolver,
            Arc::new(PassClient::new(cfg.python_upstream.clone())),
            cfg.persona_id,
        )
        .with_economy(economy)
        .with_clientdata(clientdata)
        .with_account(account)
        .with_sbc_post_commit_fault(cfg.sbc_post_commit_fault))
    }

    /// Assemble the shared squad dependencies (Core access + the one production
    /// resolver + entity tables).
    fn squad_deps(&self) -> SquadDeps<'_> {
        SquadDeps {
            core: self.core.as_ref(),
            resolver: self.resolver.as_ref(),
            entities: self.entities.as_ref(),
        }
    }

    /// Attach the FIFA17 economy authority services. Kept separate from
    /// construction so the (many) squad/club tests build a `Server` without a
    /// database, while the economy integration path wires real durable stores +
    /// the runtime bridge once.
    pub fn with_economy(mut self, economy: Arc<EconomyServices>) -> Self {
        self.economy = Some(economy);
        self
    }

    /// Attach the durable client-data blob store (configured path). Kept separate
    /// from construction so tests keep the ephemeral temp-file store.
    pub fn with_clientdata(mut self, clientdata: Arc<ClientDataStore>) -> Self {
        self.clientdata = clientdata;
        self
    }

    /// Attach the shared durable FIFA17 account store.
    pub fn with_account(mut self, account: Arc<AccountStore>) -> Self {
        self.account = account;
        self
    }

    fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self {
        self.sbc_post_commit_fault = fault;
        self
    }

    fn sbc_views(&self) -> Result<Vec<fifa17_sbc::ChallengeView>, CoreError> {
        let definitions = self.core.list_sbcs()?;
        let completions = self.core.sbc_completion_counts()?;
        Ok(definitions
            .into_iter()
            .filter_map(|definition| {
                let identity = fifa17_sbc::identity_for_core(&definition.id)?;
                Some(fifa17_sbc::ChallengeView {
                    identity,
                    name: definition.name,
                    description: definition.description,
                    repeatable: definition.repeatable,
                    times_completed: completions.get(&definition.id).copied().unwrap_or(0),
                })
            })
            .collect())
    }

    fn sbc_core_ids_from_wire(
        &self,
        service: &EconomyServices,
        wire_ids: &[i64],
    ) -> Result<Vec<String>, WireResponse> {
        let owned: std::collections::HashSet<String> = self
            .core
            .all_owned()
            .map_err(core_sbc_error_response)?
            .into_iter()
            .map(|item| item.owned_card_id)
            .collect();
        let mut seen = std::collections::HashSet::with_capacity(wire_ids.len());
        let mut core_ids = Vec::with_capacity(wire_ids.len());
        for wire_id in wire_ids {
            if !seen.insert(*wire_id) {
                return Err(json_status(
                    400,
                    &json!({ "error": format!("duplicate SBC item id {wire_id}") }),
                ));
            }
            let core_id = self.resolver.owned_id_for_wire(*wire_id).ok_or_else(|| {
                json_status(
                    404,
                    &json!({ "error": format!("unknown SBC item id {wire_id}") }),
                )
            })?;
            if !owned.contains(&core_id) {
                return Err(json_status(
                    404,
                    &json!({ "error": format!("SBC item {wire_id} is not owned") }),
                ));
            }
            self.ensure_sbc_host_eligible(service, &core_id)?;
            core_ids.push(core_id);
        }
        Ok(core_ids)
    }

    fn ensure_sbc_host_eligible(
        &self,
        service: &EconomyServices,
        core_id: &str,
    ) -> Result<(), WireResponse> {
        let piles = service.piles.clone();
        let id = core_id.to_owned();
        let pile = service
            .bridge
            .block_on(async move { piles.get(&id).await })
            .map_err(|error| {
                json_status(
                    503,
                    &json!({ "error": format!("SBC pile eligibility unavailable: {error}") }),
                )
            })?;
        if matches!(pile.as_deref(), Some("purchased" | "unassigned")) {
            return Err(json_status(
                409,
                &json!({ "error": "purchased/unassigned items are not SBC-eligible" }),
            ));
        }

        let market = service.market.clone();
        let id = core_id.to_owned();
        let listed = service
            .bridge
            .block_on(async move { market.has_active_for_core_item(&id).await })
            .map_err(|error| {
                json_status(
                    503,
                    &json!({ "error": format!("SBC listing eligibility unavailable: {error}") }),
                )
            })?;
        if listed {
            return Err(json_status(
                409,
                &json!({ "error": "actively listed items are not SBC-eligible" }),
            ));
        }
        Ok(())
    }

    fn sbc_wire_ids_from_core(&self, core_ids: &[String]) -> Result<Vec<i64>, WireResponse> {
        let owned: std::collections::HashMap<String, CoreOwnedItem> = self
            .core
            .all_owned()
            .map_err(core_sbc_error_response)?
            .into_iter()
            .map(|item| (item.owned_card_id.clone(), item))
            .collect();
        core_ids
            .iter()
            .map(|core_id| {
                let item = owned.get(core_id).ok_or_else(|| {
                    json_status(409, &json!({ "error": "saved SBC squad is stale" }))
                })?;
                self.resolver
                    .resolve(item)
                    .map(|identity| i64::from(identity.item_id))
                    .ok_or_else(|| {
                        json_status(
                            409,
                            &json!({ "error": "saved SBC item has no FIFA identity" }),
                        )
                    })
            })
            .collect()
    }

    fn handle_sbc_route(
        &self,
        service: &EconomyServices,
        route: EconomyRoute,
        method: &str,
        path: &str,
        body: &[u8],
    ) -> WireResponse {
        match route {
            EconomyRoute::SbcSets => match self.sbc_views() {
                Ok(views) => json_status(200, &fifa17_sbc::sets_body(&views)),
                Err(error) => core_sbc_error_response(error),
            },
            EconomyRoute::SbcTag => json_status(200, &json!({})),
            EconomyRoute::SbcChallenges => {
                let Some(set_id) = ut_tail(path).and_then(sbc_set_id) else {
                    return json_status(400, &json!({ "error": "invalid SBC set id" }));
                };
                match self.sbc_views() {
                    Ok(views) => json_status(200, &fifa17_sbc::challenges_body(set_id, &views)),
                    Err(error) => core_sbc_error_response(error),
                }
            }
            EconomyRoute::SbcChallengeSquad => {
                let Some(challenge_id) = ut_tail(path).and_then(sbc_challenge_id) else {
                    return json_status(400, &json!({ "error": "invalid SBC challenge id" }));
                };
                let Some(identity) = fifa17_sbc::identity_for_challenge(challenge_id) else {
                    return json_status(404, &json!({ "error": "unknown SBC challenge" }));
                };
                if method.eq_ignore_ascii_case("GET") {
                    let core_ids = match self.core.load_sbc_squad(identity.core_id) {
                        Ok(ids) => ids,
                        Err(error) => return core_sbc_error_response(error),
                    };
                    let wire_ids = match self.sbc_wire_ids_from_core(&core_ids) {
                        Ok(ids) => ids,
                        Err(response) => return response,
                    };
                    return json_status(200, &fifa17_sbc::squad_body(challenge_id, &wire_ids));
                }
                let wire_ids = match fifa17_sbc::parse_wire_item_ids(body) {
                    Ok(ids) => ids,
                    Err(error) => return json_status(400, &json!({ "error": error.to_string() })),
                };
                let core_ids = match self.sbc_core_ids_from_wire(service, &wire_ids) {
                    Ok(ids) => ids,
                    Err(response) => return response,
                };
                match self.core.save_sbc_squad(identity.core_id, &core_ids) {
                    Ok(_) => json_status(200, &fifa17_sbc::save_body(challenge_id)),
                    Err(error) => core_sbc_error_response(error),
                }
            }
            EconomyRoute::SbcChallenge => {
                let Some(challenge_id) = ut_tail(path).and_then(sbc_challenge_id) else {
                    return json_status(400, &json!({ "error": "invalid SBC challenge id" }));
                };
                let Some(identity) = fifa17_sbc::identity_for_challenge(challenge_id) else {
                    return json_status(404, &json!({ "error": "unknown SBC challenge" }));
                };
                if method.eq_ignore_ascii_case("POST") && body.is_empty() {
                    return json_status(200, &fifa17_sbc::start_body(challenge_id));
                }

                let core_ids = match fifa17_sbc::parse_wire_item_ids(body) {
                    Ok(wire_ids) => match self.sbc_core_ids_from_wire(service, &wire_ids) {
                        Ok(ids) => ids,
                        Err(response) => return response,
                    },
                    Err(fifa17_sbc::SbcWireError::MissingSquad) => {
                        let ids = match self.core.load_sbc_squad(identity.core_id) {
                            Ok(ids) => ids,
                            Err(error) => return core_sbc_error_response(error),
                        };
                        let wire_ids = match self.sbc_wire_ids_from_core(&ids) {
                            Ok(ids) => ids,
                            Err(response) => return response,
                        };
                        match self.sbc_core_ids_from_wire(service, &wire_ids) {
                            Ok(ids) => ids,
                            Err(response) => return response,
                        }
                    }
                    Err(error) => return json_status(400, &json!({ "error": error.to_string() })),
                };
                let result = match self.core.submit_sbc(identity.core_id, &core_ids) {
                    Ok(result) => result,
                    Err(error) => return core_sbc_error_response(error),
                };
                if !result.passed {
                    return json_status(
                        400,
                        &json!({ "error": "SBC requirements not met", "failures": result.failures }),
                    );
                }

                for core_id in &core_ids {
                    let piles = service.piles.clone();
                    let id = core_id.clone();
                    if let Err(error) = service
                        .bridge
                        .block_on(async move { piles.remove(&id).await })
                    {
                        eprintln!(
                            "utas-host WARN SBC stale pile cleanup core_id={core_id} error={error}"
                        );
                    }
                }
                let credits = match service.econ.balance() {
                    Ok(balance) => balance,
                    Err(error) => return core_sbc_error_response(error),
                };
                let unopened_packs = match service.econ.entitlements() {
                    Ok(packs) => packs.len() as i64,
                    Err(error) => return core_sbc_error_response(error),
                };
                let mut response = json_status(
                    200,
                    &fifa17_sbc::submit_body(
                        challenge_id,
                        identity.set_id,
                        credits,
                        unopened_packs,
                    ),
                );
                response.transport = self.sbc_post_commit_fault.into();
                response
            }
            _ => json_status(500, &json!({ "error": "invalid SBC route dispatch" })),
        }
    }

    /// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path
    /// is not an economy route (or no economy services are wired). `handle_with_ip`
    /// calls this before generic route classification, so matched routes never fall
    /// through to Python. Sync handlers run inline; async store/market handlers use
    /// the shared runtime bridge.
    pub fn try_handle_economy(
        &self,
        method: &str,
        target: &str,
        headers: &[(String, String)],
        body: &[u8],
        client_ip: Option<&str>,
    ) -> Option<WireResponse> {
        let svc = self.economy.as_ref()?;
        let _economy_guard = self.economy_gate.lock().unwrap();
        let path = target.split('?').next().unwrap_or(target);
        let route = classify_economy(method, path)?;
        use crate::economy_store::{
            handle_pack_open, handle_quick_sell_body, handle_quick_sell_path, handle_store_buy,
            CoreItemLookup, QuickSellDeps, StoreDeps,
        };
        let resp = match route {
            EconomyRoute::Credits => handle_credits(svc.econ.as_ref()),
            EconomyRoute::PurchaseGroup => {
                let sid = header(headers, "x-ut-sid").unwrap_or("");
                let mode =
                    self.sessions
                        .lock()
                        .unwrap()
                        .empty_mypacks_mode(sid, client_ip, self.now());
                handle_purchasegroup(svc.econ.as_ref(), mode)
            }
            EconomyRoute::StoreBuy => {
                let mut rng = rand::rngs::StdRng::from_entropy();
                let sink = BridgedPurchasedSink {
                    bridge: svc.bridge.clone(),
                    piles: svc.piles.clone(),
                };
                let deps = StoreDeps {
                    econ: svc.econ.as_ref(),
                    assets: self.resolver.as_ref(),
                    entities: self.entities.as_ref(),
                    pool: svc.pool.as_ref(),
                    purchased: Some(&sink),
                };
                handle_store_buy(body, &deps, &mut rng)
            }
            EconomyRoute::PackOpen => {
                let mut rng = rand::rngs::StdRng::from_entropy();
                let sink = BridgedPurchasedSink {
                    bridge: svc.bridge.clone(),
                    piles: svc.piles.clone(),
                };
                let deps = StoreDeps {
                    econ: svc.econ.as_ref(),
                    assets: self.resolver.as_ref(),
                    entities: self.entities.as_ref(),
                    pool: svc.pool.as_ref(),
                    purchased: Some(&sink),
                };
                handle_pack_open(body, &deps, &mut rng)
            }
            EconomyRoute::PackReveal => {
                // Async pile membership via the bridge; Core inventory read
                // synchronously on this (non-runtime) dispatch thread; pure shape.
                let (bridge, piles) = (svc.bridge.clone(), svc.piles.clone());
                let purchased_ids: std::collections::HashSet<String> = bridge
                    .block_on(async move { piles.list_by_pile("purchased").await })
                    .unwrap_or_default()
                    .into_iter()
                    .collect();
                let owned = self.core.all_owned().unwrap_or_default();
                crate::economy_store::shape_purchased_reveal(
                    &owned,
                    &purchased_ids,
                    self.entities.as_ref(),
                    self.resolver.as_ref(),
                )
            }
            EconomyRoute::QuickSellPath => {
                let id = ut_tail(path)
                    .and_then(|t| t.strip_prefix("item/"))
                    .and_then(|d| d.parse::<i64>().ok())?;
                let lookup = CoreItemLookup {
                    core: self.core.as_ref(),
                };
                let deps = QuickSellDeps {
                    econ: svc.econ.as_ref(),
                    reverse: self.resolver.as_ref(),
                    items: &lookup,
                };
                handle_quick_sell_path(id, &deps)
            }
            EconomyRoute::QuickSellBody => {
                let lookup = CoreItemLookup {
                    core: self.core.as_ref(),
                };
                let deps = QuickSellDeps {
                    econ: svc.econ.as_ref(),
                    reverse: self.resolver.as_ref(),
                    items: &lookup,
                };
                handle_quick_sell_body(body, &deps)
            }
            EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), self.persona_id, body),
            EconomyRoute::MoveItems => {
                let (bridge, piles, resolver, market) = (
                    svc.bridge.clone(),
                    svc.piles.clone(),
                    self.resolver.clone(),
                    svc.market.clone(),
                );
                let body = body.to_vec();
                bridge.block_on(async move {
                    crate::market::handle_move_items(
                        &body,
                        resolver.as_ref(),
                        piles.as_ref(),
                        market.as_ref(),
                    )
                    .await
                })
            }
            EconomyRoute::MarketList => {
                // Resolve the listed item synchronously (identity + one Core read)
                // on the dispatch thread, so the async persist future holds only
                // `Send` data (the trait-object resolvers are not `Send`).
                let resolved = {
                    let lookup = CoreItemLookup {
                        core: self.core.as_ref(),
                    };
                    crate::market::resolve_market_list(
                        body,
                        self.resolver.as_ref(),
                        self.resolver.as_ref(),
                        &lookup,
                        self.entities.as_ref(),
                    )
                };
                let (bridge, market, econ) =
                    (svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
                let m = method.to_string();
                bridge.block_on(async move {
                    crate::market::handle_market_list(&m, resolved, econ.as_ref(), market.as_ref())
                        .await
                })
            }
            EconomyRoute::MarketQuery => {
                let (bridge, market, econ) =
                    (svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
                let exp = svc.sold_experiment;
                bridge.block_on(async move {
                    crate::market::handle_market_query(
                        "active",
                        econ.as_ref(),
                        market.as_ref(),
                        exp,
                    )
                    .await
                })
            }
            EconomyRoute::MarketCounts => {
                let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
                let exp = svc.sold_experiment;
                bridge.block_on(async move {
                    crate::market::handle_market_counts(market.as_ref(), exp).await
                })
            }
            EconomyRoute::MarketStatus => {
                let (bridge, market, econ) =
                    (svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
                let exp = svc.sold_experiment;
                // The raw query carries `tradeIds`; `path` is already stripped.
                let q = target.split_once('?').map(|(_, q)| q.to_string());
                bridge.block_on(async move {
                    crate::market::handle_market_status(
                        q.as_deref(),
                        econ.as_ref(),
                        market.as_ref(),
                        exp,
                    )
                    .await
                })
            }
            EconomyRoute::MarketBuy => {
                let (bridge, market, econ) =
                    (svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
                let (m, p, body) = (method.to_string(), path.to_string(), body.to_vec());
                bridge.block_on(async move {
                    crate::market::handle_market_buy(&m, &p, &body, econ.as_ref(), market.as_ref())
                        .await
                })
            }
            EconomyRoute::MarketCancel => {
                let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
                let (p, owner) = (path.to_string(), client_ip.map(|s| s.to_string()));
                bridge.block_on(async move {
                    crate::market::handle_market_cancel(&p, owner.as_deref(), market.as_ref()).await
                })
            }
            route @ (EconomyRoute::SbcSets
            | EconomyRoute::SbcTag
            | EconomyRoute::SbcChallenges
            | EconomyRoute::SbcChallengeSquad
            | EconomyRoute::SbcChallenge) => {
                self.handle_sbc_route(svc.as_ref(), route, method, path, body)
            }
            EconomyRoute::MarketClearSold => {
                let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
                bridge.block_on(async move {
                    crate::market::handle_market_clear_sold(market.as_ref()).await
                })
            }
        };
        Some(resp)
    }

    /// 4-arg entrypoint (tests + callers without a peer address). Session-bound
    /// routes fall back to a `None` client IP.
    pub fn handle(
        &self,
        method: &str,
        target: &str,
        headers: &[(String, String)],
        body: &[u8],
    ) -> WireResponse {
        self.handle_with_ip(method, target, headers, body, None)
    }

    /// Route one request to a response. Classification happens here, once, before
    /// either branch runs. `client_ip` is the peer address used to bind FIFA
    /// session capability (auxiliary to the authoritative X-UT-SID).
    pub fn handle_with_ip(
        &self,
        method: &str,
        target: &str,
        headers: &[(String, String)],
        body: &[u8],
        client_ip: Option<&str>,
    ) -> WireResponse {
        let path = target.split('?').next().unwrap_or(target);
        // ─────────────────────── Economy authority barrier ───────────────────
        // Every economy-touching route is owned by Rust/Core. Classified and
        // dispatched HERE, before `classify()`, so a migrated route can NEVER also
        // reach the Python passthrough (NEVER BOTH). When the economy services are
        // wired (production `from_config`), an economy route ALWAYS returns `Some`
        // — fail-closed (503) on any Core error — so there is no Python economy
        // fallback. `None` means "not an economy route" (or no economy wired, i.e.
        // a bare test server), which falls through to the classifier below.
        if let Some(resp) = self.try_handle_economy(method, target, headers, body, client_ip) {
            eprintln!(
                "utas-host owner=RUST route=economy method={} path={} status={}",
                method, path, resp.status
            );
            return resp;
        }
        match classify(method, path) {
            Route::Club => {
                let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
                let hidden = self.club_hidden_ids();
                let deps = ClubDeps {
                    core: self.core.as_ref(),
                    entities: self.entities.as_ref(),
                    assets: self.resolver.as_ref(),
                    hidden: &hidden,
                };
                let (resp, log) = handle_club(query, &deps);
                eprintln!(
                    "utas-host owner=RUST route=club status={} outcome={} filter=[{}] total={} emitted={} dropped_no_asset={} offset={:?} limit={:?}",
                    resp.status, log.outcome, log.filter, log.total, log.emitted, log.dropped_no_asset, log.offset, log.limit
                );
                resp
            }
            Route::ClubRename => self.handle_club_rename(body),
            Route::SquadReplace => {
                let deps = self.squad_deps();
                let (resp, log) = handle_put_squad(body, &deps);
                eprintln!(
                    "utas-host owner=RUST route=squad-replace status={} outcome={} detail=[{}]",
                    resp.status, log.outcome, log.detail
                );
                resp
            }
            Route::SquadList => {
                let deps = self.squad_deps();
                let (resp, log) = handle_squad_list(&deps);
                eprintln!(
                    "utas-host owner=RUST route=squad-list status={} outcome={} detail=[{}]",
                    resp.status, log.outcome, log.detail
                );
                resp
            }
            Route::SquadActive => {
                let deps = self.squad_deps();
                let (resp, log) = handle_squad_active(&deps, self.persona_id);
                eprintln!(
                    "utas-host owner=RUST route=squad-active status={} outcome={} detail=[{}]",
                    resp.status, log.outcome, log.detail
                );
                resp
            }
            Route::UserMassInfo => self.handle_user_mass_info_full(),
            Route::Auth => self.handle_auth(method, target, headers, body, client_ip),
            Route::AccountSync => self.handle_account_sync(body),
            Route::ClientData => self.handle_client_data(method, path, body),
            Route::Capability => self.handle_capability(body, client_ip),
            Route::StorePurchaseGroup => {
                self.handle_store_purchasegroup(method, target, headers, body, client_ip)
            }
            Route::AccountInfo => {
                eprintln!("utas-host owner=RUST route=accountinfo status=200");
                json_status(200, &non_economy::accountinfo_body())
            }
            Route::Settings => {
                eprintln!("utas-host owner=RUST route=settings status=200");
                json_status(200, &non_economy::settings_body())
            }
            Route::LeaderboardOptions => {
                eprintln!("utas-host owner=RUST route=leaderboards-options status=200");
                json_status(200, &non_economy::leaderboard_options_body())
            }
            Route::MatchReset => {
                eprintln!("utas-host owner=RUST route=match-reset status=200");
                json_status(200, &non_economy::match_reset_body())
            }
            Route::ClubStatsStaff => {
                eprintln!("utas-host owner=RUST route=club-stats-staff status=200");
                json_status(200, &non_economy::club_stats_staff_body())
            }
            Route::Hub => self.handle_hub(),
            Route::ClubStats => self.handle_club_stats(path),
            Route::StaticAck => self.handle_static_ack(path),
            Route::WatchList => self.handle_watchlist(method),
            Route::User => self.handle_user(),
            Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
            Route::ItemDefs => self.handle_item_defs(target),
            Route::MarketData => self.handle_marketdata(path, target),
            Route::SecurityQuestion => self.handle_security_question(method, target, headers),
            Route::Passthrough => {
                let resp = match self.pass.forward(method, target, headers, body) {
                    Ok(r) => r,
                    Err(e) => {
                        eprintln!("utas-host ERROR passthrough to Python failed: {e}");
                        WireResponse {
                            status: 502,
                            headers: vec![(
                                "Content-Type".to_string(),
                                "application/json".to_string(),
                            )],
                            body: br#"{"error":"upstream unavailable"}"#.to_vec(),
                            transport: ResponseTransport::Normal,
                        }
                    }
                };
                eprintln!(
                    "utas-host owner=PYTHON_FALLBACK method={} path={} status={}",
                    method, path, resp.status
                );
                resp
            }
        }
    }

    /// Monotonic seconds since server start — the clock for session/pending TTLs.
    fn now(&self) -> f64 {
        self.start.elapsed().as_secs_f64()
    }

    /// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
    /// request body (`nucleusPersonaId`/`nuc`, else the configured persona) and
    /// opens a Rust session bound to the peer IP. Never proxied to Python. The
    /// SID is not an auth gate — only the Rust `SessionStore` consults it — so
    /// minting it in Rust is complete. `/ut/delete/auth` is a `{}` logout ack.
    fn handle_auth(
        &self,
        method: &str,
        target: &str,
        _headers: &[(String, String)],
        body: &[u8],
        client_ip: Option<&str>,
    ) -> WireResponse {
        let path = target.split('?').next().unwrap_or(target);
        if path.starts_with("/ut/delete/auth") {
            eprintln!("utas-host owner=RUST route=auth-delete status=200");
            return json_status(200, &json!({}));
        }
        let persona = non_economy::parse_auth_persona(body).unwrap_or(self.persona_id);
        let sid = format!(
            "OPENFUT-SID-{:016X}",
            rand::rngs::StdRng::from_entropy().gen::<u64>()
        );
        self.sessions.lock().unwrap().open_session(
            &sid,
            client_ip.map(|s| s.to_string()),
            persona,
            self.now(),
        );
        let epoch_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);
        let server_time = non_economy::format_utc_datetime(epoch_secs);
        eprintln!(
            "utas-host owner=RUST route=auth status=200 method={} ip={:?} persona={} sid_opened=true",
            method, client_ip, persona
        );
        json_status(200, &non_economy::auth_body(&sid, &server_time))
    }

    /// `PUT …/club` / `PUT|POST …/user/club` — persist the validated club
    /// identity in the shared account file. The response class has zero atoms,
    /// and the client disconnects on a 4xx, so every input returns `200 {}` just
    /// like the oracle; rejected/persistence outcomes remain visible in logs.
    fn handle_club_rename(&self, body: &[u8]) -> WireResponse {
        let outcome = self.account.rename_from_body(body);
        match &outcome {
            RenameOutcome::Updated => {
                eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=updated")
            }
            RenameOutcome::Unchanged => {
                eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=unchanged")
            }
            RenameOutcome::Rejected(reason) => eprintln!(
                "utas-host owner=RUST route=club-rename status=200 outcome=rejected detail={reason}"
            ),
            RenameOutcome::PersistFailed(error) => eprintln!(
                "utas-host ERROR owner=RUST route=club-rename status=200 outcome=persist-failed detail=[{error}]"
            ),
        }
        json_status(200, &json!({}))
    }

    /// `POST /openfut/account/sync` — launcher control-plane account summary. The
    /// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
    /// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
    /// Core error — never a Python fallback.
    fn handle_account_sync(&self, body: &[u8]) -> WireResponse {
        let svc = match &self.economy {
            Some(s) => s,
            None => {
                eprintln!("utas-host owner=RUST route=account-sync status=503 error=no_economy");
                return error_response(503, "core_unavailable");
            }
        };
        let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
            (Ok(c), Ok(e)) => (c, e),
            _ => {
                eprintln!("utas-host owner=RUST route=account-sync status=503 error=core");
                return error_response(503, "core_unavailable");
            }
        };
        let req = non_economy::parse_account_sync(body, self.persona_id);
        eprintln!(
            "utas-host owner=RUST route=account-sync status=200 persona={} coins={} packs={}",
            req.persona_id,
            coins,
            ents.len()
        );
        let club = self.account.club();
        json_status(
            200,
            &non_economy::account_sync_body(&req, coins, ents.len(), &club.name, &club.abbr),
        )
    }

    /// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store. GET
    /// returns the stored blob for `<persona>:<key>` (or `{}` if never written);
    /// PUT/POST parse and store the body under the key and ALWAYS ack `{}`.
    fn handle_client_data(&self, method: &str, path: &str, body: &[u8]) -> WireResponse {
        let key = ut_tail(path)
            .and_then(|t| t.strip_prefix("clientdata/"))
            .unwrap_or("");
        if method.eq_ignore_ascii_case("GET") {
            let blob = self
                .clientdata
                .get(self.persona_id, key)
                .unwrap_or_else(|| json!({}));
            eprintln!("utas-host owner=RUST route=clientdata method=GET key={key} status=200");
            json_status(200, &blob)
        } else {
            let val: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
            self.clientdata.put(self.persona_id, key, val);
            eprintln!(
                "utas-host owner=RUST route=clientdata method={method} key={key} status=200 stored=true"
            );
            json_status(200, &json!({}))
        }
    }

    /// Build the full Rust userMassInfo Value (userInfo + squad + settings +
    /// pileSizeClientData) from Core, or a 503 response on Core error. Shared by
    /// `GET …/userMassInfo` and `GET …/user` so both agree byte-for-byte.
    fn build_user_mass_info(&self) -> Result<(Value, &'static str), WireResponse> {
        let svc = match &self.economy {
            Some(s) => s,
            None => return Err(error_response(503, "core_unavailable")),
        };
        let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
            (Ok(c), Ok(e)) => (c, e),
            _ => return Err(error_response(503, "core_unavailable")),
        };
        let deps = self.squad_deps();
        let (squad, squad_outcome) = match project_active_squad(&deps) {
            HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
            HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
            HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
            HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
        };
        let club = self.account.club();
        Ok((
            non_economy::user_mass_info_body(
                squad,
                coins,
                ents.len(),
                self.persona_id,
                &club.name,
                &club.abbr,
                &club.established,
            ),
            squad_outcome,
        ))
    }

    /// `GET …/userMassInfo` — served FULLY from Rust (no Python): the Core squad
    /// projection (byte-identical to `GET …/squad/active`) plus the authoritative
    /// Core economy (coins + unopened packs). Fail-closed 503 on Core error.
    fn handle_user_mass_info_full(&self) -> WireResponse {
        match self.build_user_mass_info() {
            Ok((body, squad_outcome)) => {
                eprintln!(
                    "utas-host owner=RUST route=userMassInfo status=200 squad_outcome={squad_outcome}"
                );
                json_status(200, &body)
            }
            Err(e) => {
                eprintln!(
                    "utas-host owner=RUST route=userMassInfo status={} error=core",
                    e.status
                );
                e
            }
        }
    }

    /// `GET …/user` — the FUT user profile: `{"userInfo": …}`, the same userInfo
    /// object `userMassInfo` embeds (shared builder). Fail-closed 503 on Core error.
    fn handle_user(&self) -> WireResponse {
        match self.build_user_mass_info() {
            Ok((body, _)) => {
                let user_info = body.get("userInfo").cloned().unwrap_or_else(|| json!({}));
                eprintln!("utas-host owner=RUST route=user status=200");
                json_status(200, &json!({ "userInfo": user_info }))
            }
            Err(e) => {
                eprintln!(
                    "utas-host owner=RUST route=user status={} error=core",
                    e.status
                );
                e
            }
        }
    }

    /// `POST /openfut/fifa17/capability` — Rust-owned launcher registration (no
    /// economy, no proxy). Fail-closed: an unsupported name/version is a 400 that
    /// records nothing, so the session stays on the sentinel fallback.
    fn handle_capability(&self, body: &[u8], client_ip: Option<&str>) -> WireResponse {
        let req = match parse_capability_request(body) {
            Ok(r) => r,
            Err(()) => {
                eprintln!("utas-host owner=RUST route=capability status=400 outcome=unsupported");
                return json_status(400, &json!({"error": "unsupported capability"}));
            }
        };
        let persona = req.persona_id.unwrap_or(self.persona_id);
        let outcome = self.sessions.lock().unwrap().register_capability(
            client_ip.map(|s| s.to_string()),
            persona,
            req.version,
            self.now(),
        );
        eprintln!(
            "utas-host owner=RUST route=capability status=200 ip={:?} persona={} -> {:?}",
            client_ip, persona, outcome
        );
        json_status(200, &json!({"status": "OK"}))
    }

    /// `GET …/store/purchasegroup…` — proxy to Python for the authoritative economy
    /// body (catalogue + owned packs + coins), then overlay ONLY the empty-My-Packs
    /// topology from the Rust session mode. Python (which does not know the Rust
    /// capability) always emits the 65534 sentinel when My Packs is empty; for a
    /// verified clean-v1 SID we strip it. Rust never writes economy state.
    fn handle_store_purchasegroup(
        &self,
        method: &str,
        target: &str,
        headers: &[(String, String)],
        body: &[u8],
        client_ip: Option<&str>,
    ) -> WireResponse {
        let mut resp = match self.pass.forward(method, target, headers, body) {
            Ok(r) => r,
            Err(e) => {
                eprintln!("utas-host ERROR purchasegroup proxy to Python failed: {e}");
                return error_response(502, "upstream_unavailable");
            }
        };
        let sid = header(headers, "x-ut-sid").unwrap_or("");
        // Freeze the session's empty-My-Packs mode at this first store request.
        let mode = self
            .sessions
            .lock()
            .unwrap()
            .empty_mypacks_mode(sid, client_ip, self.now());
        let mut stripped = 0usize;
        if (200..300).contains(&resp.status) {
            if let Ok(mut root) = serde_json::from_slice::<Value>(&resp.body) {
                stripped = overlay_empty_mypacks(&mut root, mode);
                if stripped > 0 {
                    if let Ok(new_body) = serde_json::to_vec(&root) {
                        set_json_body(&mut resp, new_body);
                    }
                }
            }
        }
        eprintln!(
            "utas-host owner=RUST_OVERLAY route=purchasegroup status={} sid={} mode={} sentinel_stripped={}",
            resp.status, fifa17_sidlog(sid), mode.as_str(), stripped
        );
        resp
    }

    /// `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired
    /// FUT security-question service, owned entirely in Rust (no proxy, no Core).
    /// Stateless: the client-transformed answer is never stored or compared, and
    /// the trusted-device response is an invariant verified/trusted constant. The
    /// `X-UT-SID` session gate mirrors the oracle — an unknown session is a 400
    /// `invalid_session`; a malformed 32-hex device id/answer is `malformed_request`.
    fn handle_security_question(
        &self,
        method: &str,
        target: &str,
        headers: &[(String, String)],
    ) -> WireResponse {
        fn query_param(query: &str, key: &str) -> Option<String> {
            query.split('&').find_map(|kv| {
                let (k, v) = kv.split_once('=')?;
                (k == key).then(|| v.to_string())
            })
        }
        let path = target.split('?').next().unwrap_or(target);
        let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
        let tail = ut_tail(path).unwrap_or("");
        let action = non_economy::parse_security_action(tail);
        let sid = header(headers, "x-ut-sid").unwrap_or("");
        let known = self.sessions.lock().unwrap().session_known(sid);
        let device_id = query_param(query, "deviceId").unwrap_or_default();
        let question = query_param(query, "question");
        let answer = query_param(query, "answer");
        let (status, body) = non_economy::security_question_response(
            method,
            action,
            known,
            &device_id,
            question.as_deref(),
            answer.as_deref(),
        );
        eprintln!(
            "utas-host owner=RUST route=security-question status={} action={:?} sid={} known={}",
            status,
            action,
            fifa17_sidlog(sid),
            known
        );
        json_status(status, &body)
    }

    /// Core owned-instance ids that are NOT part of the CLUB view: the cards with
    /// an ACTIVE transfer-market listing. In FIFA a listed card has left the club,
    /// so it must not appear in `/club` or the hub's `clubPlayers` tally while it
    /// is on sale.
    ///
    /// Keyed on the ACTIVE LISTING, deliberately NOT on the `trade` pile. The pile
    /// can hold cards with no listing (a bare "Place on Transfer Market" move, or a
    /// listing that was cancelled/sold), and `/tradePile` renders ONLY active
    /// listings — so hiding the whole pile would make those cards invisible in BOTH
    /// views. Keying on the listing makes visibility self-healing: the moment a
    /// listing stops being active the card is back in the club, with no extra
    /// transition to maintain and no need to invent an "unlisted transfer-list"
    /// wire shape (`tradeState` has no verified spelling for that state).
    ///
    /// Empty when no economy services are wired (bare test server); a market-store
    /// read failure degrades to showing everything (never hides inventory silently).
    fn club_hidden_ids(&self) -> std::collections::HashSet<String> {
        let Some(svc) = self.economy.as_ref() else {
            return std::collections::HashSet::new();
        };
        let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
        match bridge.block_on(async move { market.query_listings("active").await }) {
            Ok(listings) => listings
                .into_iter()
                .filter_map(|l| l.core_item_id)
                .collect(),
            Err(e) => {
                eprintln!("utas-host WARN market read failed (club shows all): {e}");
                std::collections::HashSet::new()
            }
        }
    }

    /// `GET …/hub` — the FUT hub tile counts, owned in Rust (no Python). Derived
    /// from authoritative state: `clubPlayers` is the count of owned PLAYER cards
    /// in Core that are in the club (actively-listed cards excluded — they have
    /// left the club), and the auction / tradePile counts are the user's active
    /// listings in the durable market store. `clubPlayers` may be lower than the Python
    /// oracle's profile count by exactly the deferred (unnameable Legend)
    /// instances — DIFFERENT-BY-DESIGN, since deferred cards are not owned in
    /// Core. Fail-closed on Core error (503); a market-store read failure degrades
    /// the cosmetic listing counts to 0.
    fn handle_hub(&self) -> WireResponse {
        let hidden = self.club_hidden_ids();
        let club_players = match self.core.all_owned() {
            Ok(items) => items
                .iter()
                .filter(|it| !hidden.contains(&it.owned_card_id))
                .filter(|it| self.resolver.kind_of(it) == ContentKind::Player)
                .count(),
            Err(e) => {
                eprintln!("utas-host owner=RUST route=hub status=503 error=core:{e}");
                return error_response(503, "core_unavailable");
            }
        };
        let active = match &self.economy {
            Some(svc) => {
                let market = svc.market.clone();
                match svc
                    .bridge
                    .block_on(async move { market.query_listings("active").await })
                {
                    Ok(l) => l.len(),
                    Err(e) => {
                        eprintln!("utas-host WARN hub market read failed (counts=0): {e}");
                        0
                    }
                }
            }
            None => 0,
        };
        let body = json!({
            "clubPlayers": club_players,
            "auctionCount": active,
            "tradePile": { "count": active, "selling": active, "sold": 0 },
        });
        eprintln!(
            "utas-host owner=RUST route=hub status=200 clubPlayers={club_players} auctionCount={active}"
        );
        json_status(200, &body)
    }

    /// Rust-owned UNCONDITIONAL static acks — `store` eligibility gate, match
    /// keepalive, captcha, tfa, livemessage, activeMessage. Byte-identical to the
    /// Python oracle's constant responses (no flag gating), so no Python.
    fn handle_static_ack(&self, path: &str) -> WireResponse {
        let tail = ut_tail(path).unwrap_or("");
        let resp = match tail {
            "store" => json_status(200, &json!({ "result": "SUCCESS" })),
            "match/keepalive" => WireResponse {
                status: 204,
                headers: Vec::new(),
                body: Vec::new(),
                transport: ResponseTransport::Normal,
            },
            "captcha" => json_status(
                200,
                &json!({ "encodedImg": "", "sequence": 0, "sizeBeforeEncode": 0 }),
            ),
            // tfa, livemessage, activeMessage
            _ => json_status(200, &json!({})),
        };
        eprintln!(
            "utas-host owner=RUST route=static-ack tail={} status={}",
            tail, resp.status
        );
        resp
    }

    /// `…/watchList` — transfer watch list. The oracle persists no watches, so
    /// add/remove (PUT/POST/DELETE) is a bare `{}` ack; GET returns an empty watch
    /// list with the authoritative Core credits. Best-effort credits (0 on Core
    /// error) — a cosmetic balance echo, not the authoritative wallet.
    fn handle_watchlist(&self, method: &str) -> WireResponse {
        if !method.eq_ignore_ascii_case("GET") {
            eprintln!("utas-host owner=RUST route=watchlist method={method} status=200");
            return json_status(200, &json!({}));
        }
        let credits = self
            .economy
            .as_ref()
            .and_then(|s| s.econ.balance().ok())
            .unwrap_or(0);
        eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}");
        json_status(
            200,
            &json!({ "auctionInfo": [], "credits": credits, "total": 0 }),
        )
    }

    /// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
    /// club-identity read service are off, so return `{}` (parity with the
    /// flag-off Python oracle). Club rename is handled separately by
    /// [`Server::handle_club_rename`].
    fn handle_feature_off_empty(&self, path: &str) -> WireResponse {
        let tail = ut_tail(path).unwrap_or("");
        eprintln!("utas-host owner=RUST route=feature-off-empty tail={tail} status=200");
        json_status(200, &non_economy::feature_off_body())
    }

    /// `GET …/item/resource`, `…/defid` — FUT item-definition lookup. Builds
    /// `{itemData:[…]}` for every integer id (≥ 3 digits) in the query, mirroring
    /// the oracle's `defs_route` (`re.findall(r"\d{3,}")` over the raw query).
    fn handle_item_defs(&self, target: &str) -> WireResponse {
        let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
        let ids = extract_long_ints(query);
        eprintln!(
            "utas-host owner=RUST route=item-defs count={} status=200",
            ids.len()
        );
        json_status(200, &non_economy::item_defs_body(&ids))
    }

    /// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns
    /// the bare ARRAY (one band per queried defId); any other `/marketdata` returns
    /// the OBJECT band. Container type is chosen from the path (load-bearing: the
    /// wrong one froze a live client).
    fn handle_marketdata(&self, path: &str, target: &str) -> WireResponse {
        if path.ends_with("/pricelimits") {
            let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
            let ids = extract_defid_param(query);
            eprintln!(
                "utas-host owner=RUST route=marketdata kind=pricelimits count={} status=200",
                ids.len()
            );
            json_status(200, &non_economy::marketdata_pricelimits_body(&ids))
        } else {
            eprintln!("utas-host owner=RUST route=marketdata kind=object status=200");
            json_status(200, &non_economy::marketdata_object_body())
        }
    }

    /// `GET …/club/stats/<mode>` — the MY CLUB stat set, computed Core-accurately
    /// in Rust (no Python). Player tiers + rare from the Core collection,
    /// staff/consumable families from the catalog kind+subtype, and per-context
    /// buckets keyed by the screen's field: nation (year/consumables/club/…),
    /// league for `country/<id>`, team for `league/<id>`. Fail-closed 503 on Core.
    fn handle_club_stats(&self, path: &str) -> WireResponse {
        let mode = ut_tail(path)
            .and_then(|t| t.strip_prefix("club/stats/"))
            .and_then(|rest| rest.split('/').next())
            .unwrap_or("");
        // The URL says which SCREEN: `country/<id>` lists LEAGUES (leagueId
        // buckets), `league/<id>` lists TEAMS (teamid buckets); all others render
        // the default screen (nation buckets). Mirrors fut_club_stats.stats_body.
        let ctx = match mode {
            "country" => ContextField::League,
            "league" => ContextField::Team,
            _ => ContextField::Nation,
        };
        let owned = match self.core.all_owned() {
            Ok(o) => o,
            Err(e) => {
                eprintln!("utas-host owner=RUST route=club-stats status=503 error=core:{e}");
                return error_response(503, "core_unavailable");
            }
        };
        let items: Vec<ClubStatInput> = owned
            .iter()
            .map(|it| ClubStatInput {
                kind: self.resolver.kind_of(it),
                subtype: self.resolver.subtype_of(it),
                rating: it.rating as i64,
                rare: self.resolver.rareflag_of(it) != 0,
                nation_id: self.entities.nation_id(&it.nation).map(|n| n as i64),
                league_id: self.entities.league_id(&it.league).map(|n| n as i64),
                team_id: self.entities.team_id(&it.club).map(|n| n as i64),
            })
            .collect();
        let players = items
            .iter()
            .filter(|i| matches!(i.kind, ContentKind::Player))
            .count();
        eprintln!(
            "utas-host owner=RUST route=club-stats mode={} ctx={:?} status=200 owned={} players={}",
            mode,
            ctx,
            items.len(),
            players
        );
        json_status(200, &club_stats_body(&items, ctx))
    }

    /// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive).
    pub fn serve(&self, addr: &str) -> std::io::Result<()> {
        let listener = TcpListener::bind(addr)?;
        eprintln!("utas-host listening on {addr}");
        self.serve_listener(listener);
        Ok(())
    }

    /// Accept loop on an already-bound listener (lets tests bind an ephemeral
    /// port and learn it before serving).
    pub fn serve_listener(&self, listener: TcpListener) {
        for stream in listener.incoming() {
            let stream = match stream {
                Ok(s) => s,
                Err(_) => continue,
            };
            let server = self.clone();
            std::thread::spawn(move || server.handle_conn(stream));
        }
    }

    fn handle_conn(&self, stream: TcpStream) {
        let mut reader = BufReader::new(match stream.try_clone() {
            Ok(s) => s,
            Err(_) => return,
        });
        let mut writer = stream;
        let peer_ip = writer.peer_addr().ok().map(|a| a.ip().to_string());
        loop {
            match read_request(&mut reader) {
                Ok(Some(req)) => {
                    let resp = self.handle_with_ip(
                        &req.method,
                        &req.target,
                        &req.headers,
                        &req.body,
                        peer_ip.as_deref(),
                    );
                    let close = match write_response(&mut writer, &resp) {
                        Ok(close) => close,
                        Err(_) => return,
                    };
                    if close || req.close {
                        return;
                    }
                }
                Ok(None) => return, // clean EOF
                Err(_) => return,
            }
        }
    }
}

// ─────────────────────── Session/capability route helpers ───────────────────

/// Case-insensitive header lookup.
fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
    headers
        .iter()
        .find(|(k, _)| k.eq_ignore_ascii_case(name))
        .map(|(_, v)| v.as_str())
}

/// A short, non-secret tag for correlating a session in logs (last 6 chars).
fn fifa17_sidlog(sid: &str) -> String {
    if sid.is_empty() {
        "-".to_string()
    } else {
        format!("\u{2026}{}", &sid[sid.len().saturating_sub(6)..])
    }
}

/// A JSON response with an explicit status.
fn json_status(status: u16, v: &Value) -> WireResponse {
    let body = serde_json::to_vec(v).unwrap_or_default();
    WireResponse {
        status,
        headers: vec![
            ("Content-Type".to_string(), "application/json".to_string()),
            ("Content-Length".to_string(), body.len().to_string()),
        ],
        body,
        transport: ResponseTransport::Normal,
    }
}

fn core_sbc_error_response(error: CoreError) -> WireResponse {
    let status = match &error {
        CoreError::Status(400) => 400,
        CoreError::Status(404) => 404,
        CoreError::Status(409) => 409,
        _ => 503,
    };
    json_status(
        status,
        &json!({ "error": format!("Core SBC operation failed: {error}") }),
    )
}

/// A unique ephemeral JSON-state path used by [`Server::new`] tests.
/// Production replaces both stores with configured durable paths.
fn ephemeral_state_path(kind: &str) -> std::path::PathBuf {
    use std::sync::atomic::{AtomicU64, Ordering};
    static CTR: AtomicU64 = AtomicU64::new(0);
    let n = CTR.fetch_add(1, Ordering::Relaxed);
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    std::env::temp_dir().join(format!(
        "openfut-utas-{kind}-{}-{nanos}-{n}.json",
        std::process::id()
    ))
}

fn ephemeral_clientdata_path() -> std::path::PathBuf {
    ephemeral_state_path("clientdata")
}

fn ephemeral_account_path() -> std::path::PathBuf {
    ephemeral_state_path("account")
}

/// A validated capability registration request.
struct CapabilityRequest {
    persona_id: Option<i64>,
    version: u32,
}

/// Parse + validate a capability POST body. Mirrors the Python route: anything but
/// `capability == "empty_mypacks_resolver"` at the supported version is rejected.
fn parse_capability_request(body: &[u8]) -> Result<CapabilityRequest, ()> {
    let v: Value = serde_json::from_slice(body).map_err(|_| ())?;
    let obj = v.as_object().ok_or(())?;
    let name = obj.get("capability").and_then(|x| x.as_str()).ok_or(())?;
    let version = obj
        .get("version")
        .and_then(|x| x.as_u64())
        .and_then(|n| u32::try_from(n).ok())
        .ok_or(())?;
    validate_capability(name, version).map_err(|_| ())?;
    let persona_id = obj.get("personaId").and_then(|x| x.as_i64());
    Ok(CapabilityRequest {
        persona_id,
        version,
    })
}

/// Overlay the empty-My-Packs topology on Python's `purchasegroup` body. Python
/// always emits the synthetic 65534 sentinel when My Packs is empty (it does not
/// know the Rust capability); for a verified clean-v1 session we remove it so the
/// client's resolver guard routes to Browse. Sentinel mode leaves it in place, and
/// real owned packs (no 65534) are untouched in either mode. Returns the count
/// removed. Pure — the topology decision is unit-testable.
fn overlay_empty_mypacks(root: &mut Value, mode: StoreMode) -> usize {
    if mode != StoreMode::CleanV1 {
        return 0;
    }
    let Some(arr) = root.get_mut("purchase").and_then(|p| p.as_array_mut()) else {
        return 0;
    };
    let before = arr.len();
    arr.retain(|entry| {
        entry
            .get("id")
            .and_then(|x| x.as_u64())
            .map(|id| id != SENTINEL_PACK_ID)
            .unwrap_or(true)
    });
    before - arr.len()
}

// ───────────────────────────── HTTP/1.1 request reader ──────────────────────

/// A parsed request. `target` is the raw request target (path + optional query).
pub struct ParsedRequest {
    pub method: String,
    pub target: String,
    pub headers: Vec<(String, String)>,
    pub body: Vec<u8>,
    pub close: bool,
}

/// Read one HTTP/1.1 request. `Ok(None)` = clean connection close before a
/// request line. Body is read exactly per `Content-Length` (chunked is not used
/// by this client population — worker D).
pub fn read_request<R: BufRead>(reader: &mut R) -> std::io::Result<Option<ParsedRequest>> {
    let mut line = String::new();
    let n = reader.read_line(&mut line)?;
    if n == 0 {
        return Ok(None);
    }
    let request_line = line.trim_end();
    if request_line.is_empty() {
        // Tolerate a stray blank line before the request line.
        return read_request(reader);
    }
    let mut parts = request_line.split_whitespace();
    let method = parts.next().unwrap_or("").to_string();
    let target = parts.next().unwrap_or("").to_string();

    let mut headers = Vec::new();
    let mut content_length = 0usize;
    let mut close = false;
    loop {
        let mut h = String::new();
        if reader.read_line(&mut h)? == 0 {
            break;
        }
        let h = h.trim_end();
        if h.is_empty() {
            break;
        }
        if let Some((k, v)) = h.split_once(':') {
            let k = k.trim().to_string();
            let v = v.trim().to_string();
            if k.eq_ignore_ascii_case("content-length") {
                content_length = v.parse().unwrap_or(0);
            } else if k.eq_ignore_ascii_case("connection") && v.eq_ignore_ascii_case("close") {
                close = true;
            }
            headers.push((k, v));
        }
    }

    let mut body = vec![0u8; content_length];
    if content_length > 0 {
        reader.read_exact(&mut body)?;
    }

    Ok(Some(ParsedRequest {
        method,
        target,
        headers,
        body,
        close,
    }))
}

fn reason(status: u16) -> &'static str {
    match status {
        200 => "OK",
        204 => "No Content",
        400 => "Bad Request",
        404 => "Not Found",
        500 => "Internal Server Error",
        502 => "Bad Gateway",
        _ => "OK",
    }
}

fn write_response<W: Write>(w: &mut W, resp: &WireResponse) -> std::io::Result<bool> {
    match resp.transport {
        ResponseTransport::Drop => return Ok(true),
        ResponseTransport::Delay { millis } => {
            std::thread::sleep(std::time::Duration::from_millis(millis));
        }
        ResponseTransport::Normal | ResponseTransport::Malformed => {}
    }
    let mut head = format!("HTTP/1.1 {} {}\r\n", resp.status, reason(resp.status));
    for (k, v) in &resp.headers {
        if is_hop_by_hop(k) {
            continue;
        }
        head.push_str(&format!("{k}: {v}\r\n"));
    }
    head.push_str(&format!("Content-Length: {}\r\n", resp.body.len()));
    head.push_str("\r\n");
    w.write_all(head.as_bytes())?;
    if resp.transport == ResponseTransport::Malformed {
        w.write_all(b"{")?;
        w.flush()?;
        return Ok(true);
    }
    w.write_all(&resp.body)?;
    w.flush()?;
    Ok(false)
}

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

    /// A configurable in-memory economy double: real balance/entitlements, or a
    /// forced error to prove fail-closed behavior.
    struct FakeEconomy {
        balance: i64,
        entitlements: Vec<EconomyEntitlement>,
        fail: bool,
    }
    impl FakeEconomy {
        fn ok(balance: i64, ents: usize) -> Self {
            FakeEconomy {
                balance,
                entitlements: (0..ents)
                    .map(|i| EconomyEntitlement {
                        id: format!("e{i}"),
                        definition_id: "pack".into(),
                    })
                    .collect(),
                fail: false,
            }
        }
        fn failing() -> Self {
            FakeEconomy {
                balance: 0,
                entitlements: vec![],
                fail: true,
            }
        }
        fn with_entitlements(balance: i64, defs: &[&str]) -> Self {
            FakeEconomy {
                balance,
                entitlements: defs
                    .iter()
                    .enumerate()
                    .map(|(i, d)| EconomyEntitlement {
                        id: format!("e{i}"),
                        definition_id: (*d).into(),
                    })
                    .collect(),
                fail: false,
            }
        }
    }
    impl CoreEconomy for FakeEconomy {
        fn balance(&self) -> Result<i64, CoreError> {
            if self.fail {
                Err(CoreError::Status(500))
            } else {
                Ok(self.balance)
            }
        }
        fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError> {
            if self.fail {
                Err(CoreError::Status(500))
            } else {
                Ok(self.entitlements.clone())
            }
        }
        fn purchase_entitlement(
            &self,
            _cost: i64,
            definition_id: &str,
        ) -> Result<EconomyPurchase, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            Ok(EconomyPurchase {
                balance: self.balance,
                entitlement_id: format!("bought:{definition_id}"),
            })
        }
        fn redeem_entitlement(
            &self,
            _entitlement_id: &str,
            _items: &[EconomyGrantItem],
        ) -> Result<String, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            Ok("pack".into())
        }
        fn sell_item(&self, _item_id: &str, _price: i64) -> Result<i64, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            Ok(self.balance)
        }
        fn grant_reward(&self, _amount: i64) -> Result<i64, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            Ok(self.balance)
        }
        fn complete_match(
            &self,
            m: &CoreMatchCompletion<'_>,
        ) -> Result<CoreMatchReceipt, CoreError> {
            if self.fail {
                return Err(CoreError::Status(503));
            }
            let coins = match m.result {
                "win" => 400,
                "draw" => 200,
                "loss" | "dnf" => 100,
                _ => 0,
            };
            Ok(CoreMatchReceipt {
                applied: true,
                result: m.result.to_string(),
                coins_awarded: coins,
                coins_balance: self.balance,
            })
        }
        fn purchase_item(
            &self,
            _cost: i64,
            _item_id: &str,
            _card_id: &str,
        ) -> Result<i64, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            Ok(self.balance)
        }
        fn purchase_items(
            &self,
            _cost: i64,
            _items: &[EconomyGrantItem],
        ) -> Result<i64, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            Ok(self.balance)
        }
        fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
            // Sale settlement is not exercised through this double.
            Err(CoreError::Status(501))
        }
    }

    #[test]
    fn settle_sale_body_omits_absent_club_ids() {
        // Absent club ids are the wire signal for Core's defaults (active club
        // as seller, outside counterparty as buyer), so they must not appear.
        let outside = EconomySale {
            item_id: "item-x",
            seller_club_id: None,
            buyer_club_id: None,
            gross: 15_000,
            fee: 750,
        };
        let b = sale_request_body(&outside);
        assert_eq!(b["item_id"], "item-x");
        assert_eq!(b["gross"], 15_000);
        assert_eq!(b["fee"], 750);
        assert!(b.get("seller_club_id").is_none());
        assert!(b.get("buyer_club_id").is_none());

        let between_clubs = EconomySale {
            seller_club_id: Some("club-seller"),
            buyer_club_id: Some("club-buyer"),
            ..outside
        };
        let b = sale_request_body(&between_clubs);
        assert_eq!(b["seller_club_id"], "club-seller");
        assert_eq!(b["buyer_club_id"], "club-buyer");
        assert_eq!(b["item_id"], "item-x");
        assert_eq!(b["gross"], 15_000);
        assert_eq!(b["fee"], 750);
    }

    #[test]
    fn credits_body_matches_oracle_shape() {
        let b = build_credits_body(29_876_776, 0);
        assert_eq!(b["credits"], 29_876_776);
        assert_eq!(b["currencies"][0]["name"], "coins");
        assert_eq!(b["currencies"][0]["funds"], 29_876_776);
        assert_eq!(b["currencies"][0]["finalFunds"], 29_876_776);
        assert_eq!(b["currencies"][1]["name"], "points");
        assert_eq!(b["currencies"][1]["funds"], 0);
        // No packs -> no unopenedPacks key (keeps the badge off).
        assert!(b.get("unopenedPacks").is_none());
        let b2 = build_credits_body(100, 3);
        assert_eq!(b2["unopenedPacks"]["recoveredPacks"], 3);
        assert_eq!(b2["unopenedPacks"]["preOrderPacks"], 0);
    }

    #[test]
    fn handle_credits_reads_core_authority() {
        let econ = FakeEconomy::ok(4600, 2);
        let resp = handle_credits(&econ);
        assert_eq!(resp.status, 200);
        let body: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(body["currencies"][0]["funds"], 4600);
        assert_eq!(body["unopenedPacks"]["recoveredPacks"], 2);
    }

    #[test]
    fn handle_credits_fails_closed_on_core_error() {
        // Core error -> controlled 503, NEVER a Python fallback or fabricated balance.
        let resp = handle_credits(&FakeEconomy::failing());
        assert_eq!(resp.status, 503);
    }

    fn pack_ids(body: &Value) -> Vec<u64> {
        body["purchase"]
            .as_array()
            .unwrap()
            .iter()
            .map(|p| p["id"].as_u64().unwrap())
            .collect()
    }

    #[test]
    fn purchasegroup_full_gen_owned_pack_no_sentinel() {
        let econ = FakeEconomy::with_entitlements(4600, &["70"]);
        let resp = handle_purchasegroup(&econ, StoreMode::Sentinel);
        assert_eq!(resp.status, 200);
        let body: Value = serde_json::from_slice(&resp.body).unwrap();
        let ids = pack_ids(&body);
        assert!(ids.contains(&70), "owned pack 70 present");
        assert!(
            !ids.contains(&SENTINEL_PACK_ID),
            "no sentinel when a pack is owned"
        );
    }

    #[test]
    fn purchasegroup_full_gen_empty_modes() {
        // Sentinel mode + no packs -> 65534 shim present.
        let sent = handle_purchasegroup(&FakeEconomy::ok(100, 0), StoreMode::Sentinel);
        let sent_body: Value = serde_json::from_slice(&sent.body).unwrap();
        assert!(pack_ids(&sent_body).contains(&SENTINEL_PACK_ID));
        // CleanV1 + no packs -> no 65534, no My Packs group.
        let clean = handle_purchasegroup(&FakeEconomy::ok(100, 0), StoreMode::CleanV1);
        let clean_body: Value = serde_json::from_slice(&clean.body).unwrap();
        assert!(!pack_ids(&clean_body).contains(&SENTINEL_PACK_ID));
    }

    #[test]
    fn purchasegroup_fails_closed_on_core_error() {
        let resp = handle_purchasegroup(&FakeEconomy::failing(), StoreMode::Sentinel);
        assert_eq!(resp.status, 503);
    }

    #[test]
    fn massinfo_economy_overlay_sets_coins_and_packs() {
        let mut root = json!({
            "userInfo": {
                "currencies": [
                    {"name": "coins", "funds": 1, "finalFunds": 1, "active": true},
                    {"name": "points", "funds": 0, "finalFunds": 0, "active": true},
                ],
                "won": 5,
            },
            "squad": {"keep": true},
        });
        assert!(overlay_massinfo_economy(&mut root, 29_876_776, 2));
        assert_eq!(root["userInfo"]["currencies"][0]["funds"], 29_876_776);
        assert_eq!(root["userInfo"]["currencies"][0]["finalFunds"], 29_876_776);
        // points + other fields untouched; squad preserved.
        assert_eq!(root["userInfo"]["currencies"][1]["funds"], 0);
        assert_eq!(root["userInfo"]["won"], 5);
        assert_eq!(root["squad"]["keep"], true);
        assert_eq!(root["userInfo"]["unopenedPacks"]["recoveredPacks"], 2);
        // Zero packs removes the key (badge off).
        assert!(overlay_massinfo_economy(&mut root, 10, 0));
        assert!(root["userInfo"].get("unopenedPacks").is_none());
    }

    #[test]
    fn credits_massinfo_purchasegroup_agree_on_core_state() {
        // The invariant the cutover must preserve: all three read one Core state.
        let econ = FakeEconomy::with_entitlements(4600, &["70"]);
        let coins = econ.balance().unwrap();
        let count = econ.entitlements().unwrap().len();
        let credits: Value = serde_json::from_slice(&handle_credits(&econ).body).unwrap();
        let mut mass =
            json!({"userInfo": {"currencies": [{"name":"coins","funds":0,"finalFunds":0}]}});
        overlay_massinfo_economy(&mut mass, coins, count);
        let pg: Value =
            serde_json::from_slice(&handle_purchasegroup(&econ, StoreMode::Sentinel).body).unwrap();
        assert_eq!(credits["currencies"][0]["funds"], coins);
        assert_eq!(mass["userInfo"]["currencies"][0]["funds"], coins);
        assert_eq!(credits["unopenedPacks"]["recoveredPacks"], count);
        assert_eq!(mass["userInfo"]["unopenedPacks"]["recoveredPacks"], count);
        assert!(pack_ids(&pg).contains(&70));
    }

    #[test]
    fn match_reward_credits_core_and_shapes_body() {
        let econ = FakeEconomy::ok(5400, 0);
        let resp = handle_match_end(
            &econ,
            42,
            br#"{"endReason":"WIN","myMatchStats":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#,
        );
        assert_eq!(resp.status, 200);
        let body: Value = serde_json::from_slice(&resp.body).unwrap();
        // Core's authoritative balance -> allCoins; granted coins -> matchCoins.
        assert_eq!(body["allCoins"], 5400);
        assert_eq!(body["matchCoins"], 400); // win
        assert_eq!(body["gameModeAward"]["coins"], 400);
        assert_eq!(body["seasonCoins"], 0);
        // The freeze traps are never emitted.
        assert!(body.get("bidTokens").is_none());
        assert!(body.get("qualifiedChampionEventId").is_none());
        // Draw default on a well-formed body without a known endReason.
        let draw: Value =
            serde_json::from_slice(&handle_match_end(&econ, 42, br#"{"foo":1}"#).body).unwrap();
        assert_eq!(draw["matchCoins"], 200);
    }

    #[test]
    fn match_end_fails_closed_on_core_error() {
        // A Core failure NEVER falls back to Python — controlled 503.
        let resp = handle_match_end(&FakeEconomy::failing(), 42, br#"{"endReason":"WIN"}"#);
        assert_eq!(resp.status, 503);
    }

    #[test]
    fn malformed_match_end_is_rejected() {
        let resp = handle_match_end(&FakeEconomy::ok(1000, 0), 42, b"not json");
        assert_eq!(resp.status, 400);
    }

    #[test]
    fn match_identity_dedupes_retries_and_separates_matches() {
        // An identical body (a network retry of the same match) yields the SAME
        // identity so Core dedupes it; two decided matches with different stats
        // get DIFFERENT identities so both credit.
        let a = br#"{"matchReportId":0,"endReason":"WIN","myMatchStats":[3,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#;
        let b = br#"{"matchReportId":0,"endReason":"WIN","myMatchStats":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#;
        let ea = match_wire::parse_match_end(a).unwrap();
        let eb = match_wire::parse_match_end(b).unwrap();
        assert_eq!(match_identity(42, &ea, a), match_identity(42, &ea, a));
        assert_ne!(match_identity(42, &ea, a), match_identity(42, &eb, b));
        // A non-zero report id keys on the report, independent of body bytes.
        let r = br#"{"matchReportId":99,"endReason":"WIN"}"#;
        let er = match_wire::parse_match_end(r).unwrap();
        assert_eq!(match_identity(7, &er, r), "7:report:99");
    }

    #[test]
    fn special_filter_keeps_only_specials_and_paginates_filtered_set() {
        let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf });
        // base rare(1)/common(0) interleaved with specials(3,11,24).
        let items = vec![mk(1, 1), mk(2, 3), mk(3, 1), mk(4, 24), mk(5, 0), mk(6, 11)];
        let (all, total) = special_filter_page(&items, None, None);
        assert_eq!(total, 3, "only rareflag>1 counted");
        assert!(all.iter().all(|it| it["rareflag"].as_i64().unwrap() > 1));
        assert_eq!(
            all.iter()
                .map(|it| it["id"].as_i64().unwrap())
                .collect::<Vec<_>>(),
            vec![2, 4, 6],
            "base rare/common excluded, order preserved"
        );
        // pagination is over the FILTERED set, no overlap, no base leakage.
        let (p0, t0) = special_filter_page(&items, Some(0), Some(2));
        let (p1, t1) = special_filter_page(&items, Some(2), Some(2));
        assert_eq!((t0, t1), (3, 3));
        assert_eq!(
            p0.iter()
                .map(|it| it["id"].as_i64().unwrap())
                .collect::<Vec<_>>(),
            vec![2, 4]
        );
        assert_eq!(
            p1.iter()
                .map(|it| it["id"].as_i64().unwrap())
                .collect::<Vec<_>>(),
            vec![6]
        );
    }

    #[test]
    fn classify_club_read_and_rename_methods_exactly() {
        assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
        assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club);
        assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::ClubRename);
        // bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
        assert_eq!(
            classify("GET", "/ut/game/fifa17/club/stats"),
            Route::Passthrough
        );
        // club/stats/<mode> screens are Rust-owned (nation/league/team contexts).
        assert_eq!(
            classify("GET", "/ut/game/fifa17/club/stats/country/54"),
            Route::ClubStats
        );
        // clubUser is now Rust-owned (`{}` while club identity is off).
        assert_eq!(
            classify("GET", "/ut/game/fifa17/clubUser"),
            Route::FeatureOffEmpty
        );
        assert_eq!(
            classify("GET", "/ut/game/fifa17/tradePile"),
            Route::Passthrough
        );
        assert_eq!(
            classify("POST", "/ut/game/fifa17/purchased/items"),
            Route::Passthrough
        );
        assert_eq!(classify("GET", "/ut/game//club"), Route::Passthrough);
        assert_eq!(classify("GET", "/club"), Route::Passthrough);
    }

    #[test]
    fn parse_core_page_reads_collection_and_total() {
        let v = json!({
            "collection": [{
                "owned_card_id": "oc1",
                "effective_overall": 86,
                "effective_position": "CDM",
                "card": {"id":"card_ch_1","overall":85,"position":"CDM","nation":"Argentina","league":"Premier League","club":"Chelsea","pace":80,"shooting":70,"passing":75,"dribbling":78,"defending":84,"physical":82}
            }],
            "total": 42
        });
        let page = parse_core_page(&v).unwrap();
        assert_eq!(page.total, 42);
        assert_eq!(page.items.len(), 1);
        let it = &page.items[0];
        assert_eq!(it.owned_card_id, "oc1");
        assert_eq!(it.card_id, "card_ch_1");
        assert_eq!(it.rating, 86, "effective_overall wins over base");
        assert_eq!(it.position, "CDM");
        assert_eq!(it.attributes, [80, 70, 75, 78, 84, 82]);
    }

    // ── Fifa17IdentityResolver: the single production identity path ──────────

    fn test_catalog(cards: &[(&str, u32)]) -> Fifa17CardCatalog {
        let entries: Vec<String> = cards
            .iter()
            .map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}"))
            .collect();
        let doc = format!(
            "{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}",
            entries.join(",")
        );
        Fifa17CardCatalog::from_json_str(&doc).unwrap()
    }

    fn temp_store_path(tag: &str) -> std::path::PathBuf {
        use std::sync::atomic::{AtomicU64, Ordering};
        static N: AtomicU64 = AtomicU64::new(0);
        let n = N.fetch_add(1, Ordering::SeqCst);
        std::env::temp_dir().join(format!(
            "ofut-resolver-{tag}-{}-{n}.json",
            std::process::id()
        ))
    }

    fn owned(owned_id: &str, card: &str) -> CoreOwnedItem {
        CoreOwnedItem {
            owned_card_id: owned_id.into(),
            card_id: card.into(),
            rating: 90,
            position: "ST".into(),
            nation: "Argentina".into(),
            league: "Premier League".into(),
            club: "Chelsea".into(),
            attributes: [90, 90, 80, 91, 33, 80],
        }
    }

    fn resolver(path: &std::path::Path, cards: &[(&str, u32)]) -> Fifa17IdentityResolver {
        let store = openfut_identity::JsonIdentityStore::open(path).unwrap();
        Fifa17IdentityResolver::new(test_catalog(cards), Arc::new(store))
    }

    #[test]
    fn resolver_maps_definition_and_allocates_wire_id() {
        let p = temp_store_path("map");
        let r = resolver(&p, &[("card_gold_001", 20801)]);
        let id = r.resolve(&owned("oc1", "card_gold_001")).unwrap();
        assert_eq!(id.asset_id, 20801, "real asset from the catalog");
        assert_eq!(
            id.item_id, 100_000_001,
            "first wire id from the policy floor"
        );
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn resolver_drops_unmapped_definition_never_faking() {
        let p = temp_store_path("drop");
        let r = resolver(&p, &[("card_gold_001", 20801)]);
        assert!(
            r.resolve(&owned("oc1", "card_unknown")).is_none(),
            "no catalog entry => dropped, never a fabricated id"
        );
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn two_copies_of_a_definition_share_resource_but_get_distinct_wire_ids() {
        let p = temp_store_path("copies");
        let r = resolver(&p, &[("card_gold_001", 20801)]);
        let a = r.resolve(&owned("oc1", "card_gold_001")).unwrap();
        let b = r.resolve(&owned("oc2", "card_gold_001")).unwrap();
        assert_eq!(a.asset_id, b.asset_id, "same definition => same resourceId");
        assert_ne!(a.item_id, b.item_id, "distinct copies => distinct wire ids");
        // Idempotent: the same owned instance re-resolves to the same wire id.
        assert_eq!(
            r.resolve(&owned("oc1", "card_gold_001")).unwrap().item_id,
            a.item_id
        );
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn wire_id_survives_restart_and_reverses_exactly() {
        let p = temp_store_path("restart");
        let first = {
            let r = resolver(&p, &[("card_gold_001", 20801)]);
            r.resolve(&owned("oc-stable", "card_gold_001"))
                .unwrap()
                .item_id
        };
        // Reopen the same store file (simulating a host restart).
        let r2 = resolver(&p, &[("card_gold_001", 20801)]);
        let again = r2
            .resolve(&owned("oc-stable", "card_gold_001"))
            .unwrap()
            .item_id;
        assert_eq!(
            first, again,
            "same owned instance keeps its wire id across restart"
        );
        assert_eq!(
            r2.owned_id_for_wire(again as i64).as_deref(),
            Some("oc-stable"),
            "reverse lookup returns the exact owned instance"
        );
        assert_eq!(
            r2.owned_id_for_wire(999_999_999),
            None,
            "unknown wire id => None"
        );
        let _ = std::fs::remove_file(&p);
    }
    // ── Session/capability vertical (this slice) ────────────────────────────

    #[test]
    fn classify_routes_session_vertical() {
        assert_eq!(classify("POST", "/ut/auth"), Route::Auth);
        assert_eq!(classify("POST", "/ut/auth/"), Route::Auth);
        // Only POST is auth; a GET falls through to Python.
        assert_eq!(classify("GET", "/ut/auth"), Route::Passthrough);
        assert_eq!(
            classify("POST", "/openfut/fifa17/capability"),
            Route::Capability
        );
        assert_eq!(
            classify("GET", "/ut/game/fifa17/store/purchasegroup/all"),
            Route::StorePurchaseGroup
        );
        assert_eq!(
            classify("GET", "/ut/game/fifa17/store/purchasegroup/all?ppInfo=true"),
            Route::StorePurchaseGroup
        );
        // A store MUTATION stays on Python (never Rust): economy is not ours.
        assert_eq!(
            classify("PUT", "/ut/game/fifa17/store/transaction/0"),
            Route::Passthrough
        );
        assert_eq!(
            classify("POST", "/openfut/account/sync"),
            Route::AccountSync
        );
        assert_eq!(classify("POST", "/ut/delete/auth"), Route::Auth);
        assert_eq!(
            classify("PUT", "/ut/game/fifa17/clientdata/userHubData"),
            Route::ClientData
        );
        assert_eq!(
            classify("GET", "/ut/game/fifa17/clientdata/userHubData"),
            Route::ClientData
        );
    }

    #[test]
    fn parse_capability_request_validates() {
        let ok = br#"{"capability":"empty_mypacks_resolver","version":1,"personaId":33068179,"fifaPid":42}"#;
        let r = parse_capability_request(ok).expect("valid");
        assert_eq!(r.version, 1);
        assert_eq!(r.persona_id, Some(33068179));
        assert!(parse_capability_request(
            br#"{"capability":"empty_mypacks_resolver","version":2}"#
        )
        .is_err());
        assert!(parse_capability_request(br#"{"capability":"other","version":1}"#).is_err());
        assert!(parse_capability_request(b"[]").is_err());
        assert!(parse_capability_request(b"nope").is_err());
    }

    #[test]
    fn overlay_strips_sentinel_only_for_clean_v1() {
        let base = serde_json::json!({
            "purchase": [
                {"id": 1, "packType": "BRONZE"},
                {"id": 65534, "packType": "GOLD"},
                {"id": 5, "packType": "GOLD"}
            ]
        });
        // clean-v1: the 65534 sentinel is stripped; real packs remain.
        let mut clean = base.clone();
        assert_eq!(overlay_empty_mypacks(&mut clean, StoreMode::CleanV1), 1);
        let ids: Vec<u64> = clean["purchase"]
            .as_array()
            .unwrap()
            .iter()
            .map(|e| e["id"].as_u64().unwrap())
            .collect();
        assert_eq!(ids, vec![1, 5]);
        // sentinel mode: unchanged (the compatibility shim is kept).
        let mut sent = base.clone();
        assert_eq!(overlay_empty_mypacks(&mut sent, StoreMode::Sentinel), 0);
        assert_eq!(sent["purchase"].as_array().unwrap().len(), 3);
        // no purchase array -> no-op.
        let mut other = serde_json::json!({"other": 1});
        assert_eq!(overlay_empty_mypacks(&mut other, StoreMode::CleanV1), 0);
    }

    #[test]
    fn ut_tail_normalizes_v1_and_v2() {
        for (path, want) in [
            (
                "/ut/game/fifa17/store/purchasegroup",
                Some("store/purchasegroup"),
            ),
            (
                "/ut/v2/game/fifa17/store/purchasegroup",
                Some("store/purchasegroup"),
            ),
            (
                "/ut/game/fifa17/store/transaction",
                Some("store/transaction"),
            ),
            (
                "/ut/v2/game/fifa17/store/transaction/0",
                Some("store/transaction/0"),
            ),
            ("/ut/game/fifa17/purchased", Some("purchased")),
            ("/ut/v2/game/fifa17/purchased", Some("purchased")),
            ("/ut/game/fifa17/user/credits", Some("user/credits")),
            // generic sku — helper is not fifa17-string-specific.
            (
                "/ut/game/fifa23/store/transaction/7",
                Some("store/transaction/7"),
            ),
            (
                "/ut/v2/game/fifa23/store/purchasegroup",
                Some("store/purchasegroup"),
            ),
            // negatives.
            ("/ut/auth", None),
            ("/openfut/account/sync", None),
            ("/ut/game/", None),
            ("/ut/game/fifa17", None),
            ("/ut/v2/game/fifa17", None),
            ("/ut/v2/other/thing", None),
            ("/ut/delete/game/fifa17/item", None),
        ] {
            assert_eq!(ut_tail(path), want, "ut_tail({path})");
        }
    }

    #[test]
    fn is_store_transaction_tail_is_bounded() {
        assert!(is_store_transaction_tail("store/transaction"));
        assert!(is_store_transaction_tail("store/transaction/0"));
        assert!(is_store_transaction_tail("store/transaction/123"));
        assert!(!is_store_transaction_tail("store/transactions"));
        assert!(!is_store_transaction_tail("store/transactionfoo"));
        assert!(!is_store_transaction_tail("store/transaction/0/extra"));
        assert!(!is_store_transaction_tail("store/transaction/"));
        assert!(!is_store_transaction_tail("store/transaction/abc"));
        assert!(!is_store_transaction_tail("store/purchasegroup"));
    }

    #[test]
    fn classify_economy_covers_retail_v2_store_family() {
        use EconomyRoute::*;
        // The exact live-failure shape now classifies as Rust StoreBuy.
        assert_eq!(
            classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0"),
            Some(StoreBuy)
        );
        assert_eq!(
            classify_economy("PUT", "/ut/game/fifa17/store/transaction"),
            Some(StoreBuy)
        );
        assert_eq!(
            classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/123"),
            Some(StoreBuy)
        );
        // purchasegroup + purchased under both prefixes.
        assert_eq!(
            classify_economy("GET", "/ut/v2/game/fifa17/store/purchasegroup"),
            Some(PurchaseGroup)
        );
        assert_eq!(
            classify_economy("GET", "/ut/game/fifa17/store/purchasegroup/all"),
            Some(PurchaseGroup)
        );
        assert_eq!(
            classify_economy("POST", "/ut/v2/game/fifa17/purchased"),
            Some(PackOpen)
        );
        assert_eq!(
            classify_economy("GET", "/ut/v2/game/fifa17/purchased"),
            Some(PackReveal)
        );
        // v1 non-store economy routes still classify (regression).
        assert_eq!(
            classify_economy("GET", "/ut/game/fifa17/user/credits"),
            Some(Credits)
        );
        assert_eq!(
            classify_economy("PUT", "/ut/game/fifa17/item"),
            Some(MoveItems)
        );
        assert_eq!(
            classify_economy("DELETE", "/ut/game/fifa17/item/100000001"),
            Some(QuickSellPath)
        );
        assert_eq!(
            classify_economy("GET", "/ut/game/fifa17/tradePile"),
            Some(MarketQuery)
        );
        assert_eq!(
            classify_economy("POST", "/ut/delete/game/fifa17/item"),
            Some(QuickSellBody)
        );
        assert_eq!(
            classify_economy("POST", "/ut/delete/game/fifa17/match"),
            Some(MatchEnd)
        );
        // delete family under v2 prefix too (defense-in-depth symmetry).
        assert_eq!(
            classify_economy("POST", "/ut/v2/delete/game/fifa17/item"),
            Some(QuickSellBody)
        );
        // negatives: non-economy stays None (proxied to Python).
        assert_eq!(
            classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0/extra"),
            None
        );
        assert_eq!(
            classify_economy("PUT", "/ut/game/fifa17/store/transactions"),
            None
        );
        assert_eq!(classify_economy("GET", "/ut/game/fifa17/hub"), None);
        assert_eq!(classify_economy("GET", "/ut/v2/game/fifa17/store"), None);
        assert_eq!(classify_economy("POST", "/ut/auth"), None);
    }

    /// RETAIL_ROUTE_MATRIX — the audited retail economy route contract. Every
    /// economy row MUST classify to its Rust route (Python proxy forbidden);
    /// every negative near-miss MUST stay `None` (proxied). Permanent gate against
    /// "Python knows route X, Rust forgot route X".
    #[test]
    fn retail_route_matrix() {
        use EconomyRoute::*;
        let matrix: &[(&str, &str, Option<EconomyRoute>)] = &[
            // credits
            ("GET", "/ut/game/fifa17/user/credits", Some(Credits)),
            // purchasegroup (v1 + v2 + /all)
            (
                "GET",
                "/ut/game/fifa17/store/purchasegroup",
                Some(PurchaseGroup),
            ),
            (
                "GET",
                "/ut/game/fifa17/store/purchasegroup/all",
                Some(PurchaseGroup),
            ),
            (
                "GET",
                "/ut/v2/game/fifa17/store/purchasegroup/all",
                Some(PurchaseGroup),
            ),
            // store transaction (v2 + trailing id) — round-1 fix
            ("PUT", "/ut/game/fifa17/store/transaction", Some(StoreBuy)),
            (
                "PUT",
                "/ut/v2/game/fifa17/store/transaction/0",
                Some(StoreBuy),
            ),
            // purchased + purchased/items — round-2 fix (POST open, GET reveal)
            ("POST", "/ut/game/fifa17/purchased", Some(PackOpen)),
            ("POST", "/ut/game/fifa17/purchased/items", Some(PackOpen)),
            ("POST", "/ut/v2/game/fifa17/purchased/items", Some(PackOpen)),
            ("GET", "/ut/game/fifa17/purchased", Some(PackReveal)),
            ("GET", "/ut/game/fifa17/purchased/items", Some(PackReveal)),
            // move
            ("PUT", "/ut/game/fifa17/item", Some(MoveItems)),
            // quick-sell (path + body)
            (
                "DELETE",
                "/ut/game/fifa17/item/100000001",
                Some(QuickSellPath),
            ),
            ("POST", "/ut/delete/game/fifa17/item", Some(QuickSellBody)),
            (
                "POST",
                "/ut/v2/delete/game/fifa17/item",
                Some(QuickSellBody),
            ),
            // match end
            ("POST", "/ut/delete/game/fifa17/match", Some(MatchEnd)),
            // market list / query (case-insensitive tradePile + counts) / buy / cancel
            ("POST", "/ut/game/fifa17/auctionhouse", Some(MarketList)),
            ("POST", "/ut/game/fifa17/transfermarket", Some(MarketList)),
            ("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)),
            ("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)),
            // The tally is a DISTINCT deserializer from the listing list.
            (
                "GET",
                "/ut/game/fifa17/tradePile/counts",
                Some(MarketCounts),
            ),
            (
                "GET",
                "/ut/game/fifa17/tradepile/counts",
                Some(MarketCounts),
            ),
            ("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
            // The live-state poll MUST NOT land in the buy/view arm: `status` is
            // not a trade id, so that arm answers every poll with an empty set.
            ("GET", "/ut/game/fifa17/trade/status", Some(MarketStatus)),
            ("GET", "/ut/game/fifa17/TRADE/STATUS", Some(MarketStatus)),
            // Cancel: the oracle's `/ut/delete/game/…` spelling AND the plain
            // `DELETE /ut/game/…/trade/<id>` used by FIFA 17 clients. The plain
            // form previously fell into the buy/view arm and silently no-oped.
            (
                "DELETE",
                "/ut/game/fifa17/trade/900000001",
                Some(MarketCancel),
            ),
            (
                "DELETE",
                "/ut/delete/game/fifa17/trade/900000001",
                Some(MarketCancel),
            ),
            // ── negatives: must stay None (proxied to Python) ──
            ("GET", "/ut/game/fifa17/store", None),
            ("GET", "/ut/game/fifa17/store/", None),
            ("PUT", "/ut/game/fifa17/store/transactions", None),
            ("PUT", "/ut/game/fifa17/store/transaction/0/extra", None),
            ("POST", "/ut/game/fifa17/purchasedfoo", None),
            ("POST", "/ut/game/fifa17/purchased/items/extra", None),
            ("GET", "/ut/game/fifa17/hub", None),
            ("GET", "/ut/game/fifa17/marketdata", None),
            ("POST", "/ut/auth", None),
            ("GET", "/ut/game/fifa17/watchList", None),
        ];
        for (m, p, want) in matrix {
            assert_eq!(
                classify_economy(m, p),
                *want,
                "RETAIL_ROUTE_MATRIX: {m} {p}"
            );
        }
    }

    #[test]
    fn non_economy_route_ownership() {
        // The non-economy routes newly owned by Rust must classify to their arm,
        // and lookalikes must stay Passthrough (proxied to Python).
        let owned: &[(&str, &str, Route)] = &[
            (
                "GET",
                "/ut/game/fifa17/user/accountinfo",
                Route::AccountInfo,
            ),
            ("GET", "/ut/game/fifa17/settings", Route::Settings),
            (
                "GET",
                "/ut/game/fifa17/leaderboards/options",
                Route::LeaderboardOptions,
            ),
            ("PUT", "/ut/game/fifa17/match/reset", Route::MatchReset),
            (
                "GET",
                "/ut/game/fifa17/phishing/trusteddevice",
                Route::SecurityQuestion,
            ),
            (
                "POST",
                "/ut/game/fifa17/phishing/question",
                Route::SecurityQuestion,
            ),
            (
                "POST",
                "/ut/game/fifa17/phishing/validate",
                Route::SecurityQuestion,
            ),
            (
                "GET",
                "/ut/game/fifa17/club/stats/staff",
                Route::ClubStatsStaff,
            ),
            ("GET", "/ut/game/fifa17/hub", Route::Hub),
            ("GET", "/ut/game/fifa17/club/stats/year", Route::ClubStats),
            (
                "GET",
                "/ut/game/fifa17/club/stats/consumables",
                Route::ClubStats,
            ),
            (
                "PUT",
                "/ut/game/fifa17/clientdata/userHubData",
                Route::ClientData,
            ),
            (
                "GET",
                "/ut/game/fifa17/clientdata/userHubData",
                Route::ClientData,
            ),
            ("POST", "/openfut/account/sync", Route::AccountSync),
            ("GET", "/ut/game/fifa17/season", Route::FeatureOffEmpty),
            ("GET", "/ut/game/fifa17/tournament", Route::FeatureOffEmpty),
            ("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty),
            ("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty),
            ("GET", "/ut/game/fifa17/user/list", Route::FeatureOffEmpty),
            ("PUT", "/ut/game/fifa17/user/club", Route::ClubRename),
            ("POST", "/ut/game/fifa17/user/club", Route::ClubRename),
            ("PUT", "/ut/game/fifa17/club", Route::ClubRename),
            ("GET", "/ut/game/fifa17/item/resource", Route::ItemDefs),
            ("GET", "/ut/game/fifa17/defid", Route::ItemDefs),
            (
                "GET",
                "/ut/game/fifa17/marketdata/pricelimits",
                Route::MarketData,
            ),
            ("GET", "/ut/game/fifa17/marketdata", Route::MarketData),
        ];
        for (m, p, want) in owned {
            assert_eq!(classify(m, p), *want, "OWN: {m} {p}");
        }
        // Still Python (not yet migrated) / lookalikes / wrong method.
        let proxied: &[(&str, &str)] = &[
            ("GET", "/ut/game/fifa17/settingsfoo"),
            ("POST", "/ut/game/fifa17/match/reset"), // match/reset is PUT-only
            ("GET", "/ut/game/fifa17/match/reset"),
            ("PUT", "/ut/game/fifa17/user/accountinfo"),
            ("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only
            ("GET", "/ut/game/fifa17/user/club"), // unknown read, not rename
            ("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only
            ("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
        ];
        for (m, p) in proxied {
            assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}");
        }
        // These non-economy routes must NOT be economy-classified.
        for (m, p, _) in owned {
            assert_eq!(classify_economy(m, p), None, "NON-ECON: {m} {p}");
        }
    }

    #[test]
    fn item_defs_shape_matches_oracle() {
        // Two ids: the one hardcoded card (asset 20801) + a placeholder.
        assert_eq!(extract_long_ints("resourceId=20801"), vec![20801]);
        assert_eq!(
            extract_long_ints("idList=200389,200104&x=12"),
            vec![200389, 200104]
        );
        assert!(extract_long_ints("foo=ab").is_empty());

        let body = non_economy::item_defs_body(&[20801, 200389]);
        let items = body["itemData"].as_array().unwrap();
        assert_eq!(items.len(), 2);
        // Hardcoded Ronaldo.
        assert_eq!(items[0]["name"], "Ronaldo");
        assert_eq!(items[0]["rating"], 94);
        assert_eq!(items[0]["assetId"], 20801);
        assert_eq!(items[0]["resourceId"], 20801);
        // Placeholder: assetId = resourceId & 0xffffff, rating 75, "Player".
        assert_eq!(items[1]["name"], "Player");
        assert_eq!(items[1]["rating"], 75);
        assert_eq!(items[1]["assetId"], 200389);
        assert_eq!(items[1]["attributeList"].as_array().unwrap().len(), 6);
        assert!(non_economy::item_defs_body(&[])["itemData"]
            .as_array()
            .unwrap()
            .is_empty());
    }

    #[test]
    fn marketdata_container_types_are_load_bearing() {
        // /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
        assert_eq!(
            extract_defid_param("defId=200389,200104"),
            vec![200389, 200104]
        );
        let arr = non_economy::marketdata_pricelimits_body(&[200389, 200104]);
        assert!(arr.is_array(), "pricelimits must be a bare array");
        let arr = arr.as_array().unwrap();
        assert_eq!(arr.len(), 2);
        assert_eq!(arr[0]["defId"], 200389);
        assert_eq!(arr[0]["minPrice"], 150);
        assert_eq!(arr[0]["maxPrice"], 15000);
        // plain /marketdata MUST be an OBJECT (array-where-object is the same freeze).
        let obj = non_economy::marketdata_object_body();
        assert!(obj.is_object(), "plain marketdata must be an object");
        assert_eq!(obj["minPrice"], 150);
        assert_eq!(obj["maxPrice"], 15000);
    }
    #[derive(Default)]
    struct FakeSbcCore {
        owned: Mutex<Vec<CoreOwnedItem>>,
        saved: Mutex<std::collections::HashMap<String, Vec<String>>>,
        completions: Mutex<std::collections::HashMap<String, i64>>,
    }

    impl CoreAccess for FakeSbcCore {
        fn query_owned(&self, _params: &[(&str, String)]) -> Result<CorePage, CoreError> {
            let items = self.owned.lock().unwrap().clone();
            Ok(CorePage {
                total: items.len() as i64,
                items,
            })
        }

        fn read_squad_ext(&self, _namespace: &str) -> Result<CoreSquadRead, CoreError> {
            Err(CoreError::Status(501))
        }

        fn replace_squad(
            &self,
            _request: &CoreReplaceRequest,
        ) -> Result<CoreReplaceResult, CoreError> {
            Err(CoreError::Status(501))
        }

        fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
            Ok(vec![
                CoreSbcDefinition {
                    id: "sbc_bronze_upgrade".into(),
                    name: "Bronze Upgrade".into(),
                    description: "Submit two test players.".into(),
                    repeatable: true,
                },
                CoreSbcDefinition {
                    id: "sbc_hybrid_nations".into(),
                    name: "Hybrid Nations".into(),
                    description: "Submit a hybrid squad.".into(),
                    repeatable: false,
                },
            ])
        }

        fn sbc_completion_counts(
            &self,
        ) -> Result<std::collections::HashMap<String, i64>, CoreError> {
            Ok(self.completions.lock().unwrap().clone())
        }

        fn load_sbc_squad(&self, sbc_id: &str) -> Result<Vec<String>, CoreError> {
            Ok(self
                .saved
                .lock()
                .unwrap()
                .get(sbc_id)
                .cloned()
                .unwrap_or_default())
        }

        fn save_sbc_squad(
            &self,
            sbc_id: &str,
            owned_card_ids: &[String],
        ) -> Result<Vec<String>, CoreError> {
            self.saved
                .lock()
                .unwrap()
                .insert(sbc_id.to_owned(), owned_card_ids.to_vec());
            Ok(owned_card_ids.to_vec())
        }

        fn submit_sbc(
            &self,
            sbc_id: &str,
            owned_card_ids: &[String],
        ) -> Result<CoreSbcResult, CoreError> {
            if owned_card_ids.len() != 2 {
                return Ok(CoreSbcResult {
                    passed: false,
                    failures: vec!["need exactly 2 cards".into()],
                });
            }
            let mut owned = self.owned.lock().unwrap();
            if owned_card_ids
                .iter()
                .any(|id| !owned.iter().any(|item| item.owned_card_id == *id))
            {
                return Err(CoreError::Status(409));
            }
            owned.retain(|item| !owned_card_ids.contains(&item.owned_card_id));
            self.saved.lock().unwrap().remove(sbc_id);
            *self
                .completions
                .lock()
                .unwrap()
                .entry(sbc_id.to_owned())
                .or_default() += 1;
            Ok(CoreSbcResult {
                passed: true,
                failures: vec![],
            })
        }
    }

    fn sbc_test_server() -> (
        Server,
        Arc<FakeSbcCore>,
        Arc<EconomyServices>,
        Arc<Fifa17IdentityResolver>,
        [i64; 2],
    ) {
        let first = owned("sbc-owned-1", "sbc-card-1");
        let second = owned("sbc-owned-2", "sbc-card-2");
        let core = Arc::new(FakeSbcCore {
            owned: Mutex::new(vec![first.clone(), second.clone()]),
            ..FakeSbcCore::default()
        });
        let identity_path = temp_store_path("sbc-identity");
        let resolver = Arc::new(resolver(
            &identity_path,
            &[("sbc-card-1", 20_801), ("sbc-card-2", 20_802)],
        ));
        let wires = [
            i64::from(resolver.resolve(&first).unwrap().item_id),
            i64::from(resolver.resolve(&second).unwrap().item_id),
        ];
        let bridge = Arc::new(AsyncBridge::new().unwrap());
        let market_path = temp_store_path("sbc-market");
        let pile_path = temp_store_path("sbc-piles");
        let market = Arc::new(
            bridge
                .block_on(async move {
                    crate::market_store::MarketStore::open(
                        market_path.to_str().expect("UTF-8 temp market path"),
                    )
                    .await
                })
                .unwrap(),
        );
        let piles = Arc::new(
            bridge
                .block_on(async move {
                    crate::pile_store::PileStore::open(
                        pile_path.to_str().expect("UTF-8 temp pile path"),
                    )
                    .await
                })
                .unwrap(),
        );
        let services = Arc::new(EconomyServices {
            econ: Arc::new(FakeEconomy::ok(1_100, 1)),
            market,
            piles,
            bridge,
            pool: Arc::new(vec![]),
            sold_experiment: crate::sold_experiment::SoldExperiment::OFF,
        });
        let server = Server::new(
            core.clone(),
            Arc::new(Fifa17Entities::default()),
            resolver.clone(),
            Arc::new(PassClient::new("http://127.0.0.1:9")),
            1,
        )
        .with_economy(services.clone());
        (server, core, services, resolver, wires)
    }

    #[test]
    fn fifa17_sbc_route_family_is_bounded_and_rust_owned() {
        let routes = [
            ("GET", "/ut/game/fifa17/sbs/sets", EconomyRoute::SbcSets),
            ("PUT", "/ut/game/fifa17/sbs/sets/tag", EconomyRoute::SbcTag),
            (
                "GET",
                "/ut/game/fifa17/sbs/setId/1/challenges",
                EconomyRoute::SbcChallenges,
            ),
            (
                "GET",
                "/ut/game/fifa17/sbs/challenge/101/squad",
                EconomyRoute::SbcChallengeSquad,
            ),
            (
                "PUT",
                "/ut/game/fifa17/sbs/challenge/101/squad",
                EconomyRoute::SbcChallengeSquad,
            ),
            (
                "POST",
                "/ut/game/fifa17/sbs/challenge/101",
                EconomyRoute::SbcChallenge,
            ),
            (
                "PUT",
                "/ut/game/fifa17/sbs/challenge/101",
                EconomyRoute::SbcChallenge,
            ),
        ];
        for (method, path, expected) in routes {
            assert_eq!(classify_economy(method, path), Some(expected));
        }
        assert_eq!(
            classify_economy("GET", "/ut/game/fifa17/sbs/challenge/x/squad"),
            None
        );
        assert_eq!(
            classify_economy("DELETE", "/ut/game/fifa17/sbs/challenge/101"),
            None
        );
        assert_eq!(
            classify_economy("GET", "/ut/game/fifa17/sbs/sets/extra"),
            None
        );
    }

    #[test]
    fn fifa17_sbc_flow_enforces_host_eligibility_and_consumes_once() {
        let (server, core, services, resolver, wires) = sbc_test_server();
        let sets = server.handle("GET", "/ut/game/fifa17/sbs/sets", &[], b"");
        assert_eq!(sets.status, 200);
        let sets_body: Value = serde_json::from_slice(&sets.body).unwrap();
        assert!(sets_body["categories"].is_array());
        assert_eq!(
            sets_body["categories"][0]["sets"].as_array().unwrap().len(),
            2
        );

        let save_body = serde_json::to_vec(&json!({
            "players": [
                { "index": 0, "itemData": { "id": wires[0] } },
                { "index": 1, "itemData": { "id": wires[1] } }
            ]
        }))
        .unwrap();

        let piles = services.piles.clone();
        let bridge = services.bridge.clone();
        bridge
            .block_on(async move { piles.set("sbc-owned-1", "purchased").await })
            .unwrap();
        let purchased = server.handle(
            "PUT",
            "/ut/game/fifa17/sbs/challenge/101/squad",
            &[],
            &save_body,
        );
        assert_eq!(purchased.status, 409);
        assert!(core.saved.lock().unwrap().is_empty());

        let piles = services.piles.clone();
        services
            .bridge
            .block_on(async move { piles.set("sbc-owned-1", "unassigned").await })
            .unwrap();
        let unassigned = server.handle(
            "PUT",
            "/ut/game/fifa17/sbs/challenge/101/squad",
            &[],
            &save_body,
        );
        assert_eq!(unassigned.status, 409);
        assert!(core.saved.lock().unwrap().is_empty());

        let piles = services.piles.clone();
        services
            .bridge
            .block_on(async move { piles.set("sbc-owned-1", "club").await })
            .unwrap();
        let market = services.market.clone();
        services
            .bridge
            .block_on(async move {
                market
                    .create_listing(
                        "900000001",
                        "sbc-card-1",
                        Some("sbc-owned-1"),
                        Some(wires[0]),
                        Some(20_801),
                        150,
                        200,
                        Some("owner"),
                        None,
                        Some(3600),
                    )
                    .await
            })
            .unwrap();
        let listed = server.handle(
            "PUT",
            "/ut/game/fifa17/sbs/challenge/101/squad",
            &[],
            &save_body,
        );
        assert_eq!(listed.status, 409);
        let market = services.market.clone();
        services
            .bridge
            .block_on(async move { market.cancel_active_for_core_item("sbc-owned-1").await })
            .unwrap();

        let saved = server.handle(
            "PUT",
            "/ut/game/fifa17/sbs/challenge/101/squad",
            &[],
            &save_body,
        );
        assert_eq!(saved.status, 200);
        let loaded = server.handle("GET", "/ut/game/fifa17/sbs/challenge/101/squad", &[], b"");
        let loaded_body: Value = serde_json::from_slice(&loaded.body).unwrap();
        assert_eq!(loaded_body["squad"][0]["itemData"]["id"], wires[0]);
        assert_eq!(loaded_body["squad"][1]["itemData"]["id"], wires[1]);

        let submitted = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], br#"{}"#);
        assert_eq!(submitted.status, 200);
        let submitted_body: Value = serde_json::from_slice(&submitted.body).unwrap();
        assert_eq!(submitted_body["challengeId"], 101);
        assert_eq!(submitted_body["setId"], 1);
        assert_eq!(submitted_body["credits"], 1_100);
        assert_eq!(submitted_body["recoveredPacks"], 1);
        assert!(submitted_body["grantedChallengeAwards"].is_array());
        assert!(core.owned.lock().unwrap().is_empty());
        assert_eq!(
            core.completions
                .lock()
                .unwrap()
                .get("sbc_bronze_upgrade")
                .copied(),
            Some(1)
        );

        for core_id in ["sbc-owned-1", "sbc-owned-2"] {
            let piles = services.piles.clone();
            let id = core_id.to_owned();
            assert_eq!(
                services
                    .bridge
                    .block_on(async move { piles.get(&id).await })
                    .unwrap(),
                None
            );
        }
        assert_eq!(
            resolver.owned_id_for_wire(wires[0]).as_deref(),
            Some("sbc-owned-1"),
            "durable mapping remains, but no ownership-backed projection can emit it"
        );
        let reveal = server.handle("GET", "/ut/v2/game/fifa17/purchased/items", &[], b"");
        let reveal_body: Value = serde_json::from_slice(&reveal.body).unwrap();
        assert!(reveal_body["itemData"].as_array().unwrap().is_empty());

        let replay = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], &save_body);
        assert_eq!(replay.status, 404);
        assert_eq!(
            core.completions
                .lock()
                .unwrap()
                .get("sbc_bronze_upgrade")
                .copied(),
            Some(1)
        );
    }

    fn submit_sbc_with_fault(fault: SbcPostCommitFault) -> (WireResponse, Arc<FakeSbcCore>) {
        let (server, core, _services, _resolver, wires) = sbc_test_server();
        let server = server.with_sbc_post_commit_fault(fault);
        let save_body = serde_json::to_vec(&json!({
            "squad": [
                { "index": 0, "itemData": { "id": wires[0] } },
                { "index": 1, "itemData": { "id": wires[1] } }
            ]
        }))
        .unwrap();
        assert_eq!(
            server
                .handle(
                    "PUT",
                    "/ut/game/fifa17/sbs/challenge/101/squad",
                    &[],
                    &save_body,
                )
                .status,
            200
        );
        let response = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], br#"{}"#);
        (response, core)
    }

    #[test]
    fn sbc_post_commit_fault_modes_lose_only_the_receipt() {
        let (normal_response, normal_core) = submit_sbc_with_fault(SbcPostCommitFault::Off);
        let mut normal = Vec::new();
        assert!(!write_response(&mut normal, &normal_response).unwrap());
        assert!(normal.ends_with(&normal_response.body));
        assert_eq!(
            normal_core
                .completions
                .lock()
                .unwrap()
                .get("sbc_bronze_upgrade")
                .copied(),
            Some(1)
        );

        let (drop_response, drop_core) = submit_sbc_with_fault(SbcPostCommitFault::Drop);
        assert_eq!(drop_response.status, 200);
        assert_eq!(
            drop_core
                .completions
                .lock()
                .unwrap()
                .get("sbc_bronze_upgrade")
                .copied(),
            Some(1),
            "Core commit precedes the simulated connection drop"
        );
        let mut dropped = Vec::new();
        assert!(write_response(&mut dropped, &drop_response).unwrap());
        assert!(dropped.is_empty(), "drop mode writes no receipt bytes");

        let (malformed_response, malformed_core) =
            submit_sbc_with_fault(SbcPostCommitFault::Malformed);
        assert_eq!(
            malformed_core
                .completions
                .lock()
                .unwrap()
                .get("sbc_bronze_upgrade")
                .copied(),
            Some(1)
        );
        let mut malformed = Vec::new();
        assert!(write_response(&mut malformed, &malformed_response).unwrap());
        assert!(malformed.ends_with(b"{"));
        assert!(
            malformed_response.body.len() > 1,
            "advertised body is deliberately truncated"
        );

        let (delayed_response, delayed_core) =
            submit_sbc_with_fault(SbcPostCommitFault::Delay { millis: 20 });
        let started = Instant::now();
        let mut delayed = Vec::new();
        assert!(!write_response(&mut delayed, &delayed_response).unwrap());
        assert!(started.elapsed() >= std::time::Duration::from_millis(20));
        assert!(delayed.ends_with(&delayed_response.body));
        assert_eq!(
            delayed_core
                .completions
                .lock()
                .unwrap()
                .get("sbc_bronze_upgrade")
                .copied(),
            Some(1)
        );
    }
}
