//! FIFA 17 "My Squad" owned-player search: parse the RS4 `club` query and map
//! FIFA 17 wire encodings to the **game-independent** semantic values OpenFUT
//! Core understands.
//!
//! ## The boundary this enforces
//!
//! The FIFA 17 client sends its owned-player search as query params on
//! `GET /ut/game/fifa17/club`, e.g.
//! `?year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10`.
//! Two encoding families appear: **string enums** (`level`, `rare`, `position`)
//! and **numeric FIFA entity ids** (`nation`, `league`, `team`).
//!
//! **Numeric FIFA ids must never reach Core.** Core filters on semantic names
//! ("Premier League", "Chelsea", "Argentina"), so this adapter resolves each id
//! to a name via an injected [`EntityResolver`]. An id the resolver cannot map is
//! a hard [`MapError`] — never a silent passthrough of the raw number, which is
//! exactly how a game-specific id would leak into the generic layer.
//!
//! ## Evidence-grounded semantics (see vault Protocol Findings / Endpoint Map)
//!
//! * `level=gold` → semantic quality tier. Grounded in FIFA 17's own convention
//!   (`fut_cards.py::tier`, gold ≥ 75). `level=any` (the always-present default)
//!   → no quality constraint.
//! * `position` / `nation` / `league` / `team` → applied. The Python oracle
//!   applied only `league`+`team`; applying the rest is a deliberate correction
//!   of a proven bug, not a guess (each maps to a card attribute Core already
//!   stores). FIFA `team` is Core `club`.
//! * `start` / `count` → semantic `offset` / `limit`. The oracle ignored both and
//!   re-served page one forever; Core paginates for real. The client's 11-count /
//!   10-step windowing is a UI convention and stays out of Core.
//! * `sort=desc` → **dropped**. No sort key was ever proven (the oracle does not
//!   sort); Core imposes its own deterministic order. We do not invent a named
//!   FIFA sort mode.
//! * `rare=SP` ("Special") → **UNKNOWN and unsupported.** The oracle never reads
//!   it and no committed metadata grounds "SP" to a card set. It is recorded in
//!   [`CoreOwnedQuery::unsupported`] and deliberately produces **no** Core filter.
//!
//! ## Decoupling
//!
//! This module does not depend on `openfut-core`. The contract between the two is
//! the set of Core `/collection` query-parameter *names* emitted by
//! [`CoreOwnedQuery::to_query_pairs`]; they mirror Core's `OwnedItemQuery` fields
//! and are pinned by a test so drift is caught.

use std::collections::HashMap;

/// The FIFA 17 club-search query exactly as it arrives on the wire. Numeric
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Fifa17ClubQuery {
    /// Quality filter: `any` (default, always present) or `gold`.
    pub level: Option<String>,
    /// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
    pub rare: Option<String>,
    /// Playing position, e.g. `ST`.
    pub position: Option<String>,
    /// FIFA nation id (e.g. 52 = Argentina).
    pub nation: Option<u32>,
    /// FIFA league id (e.g. 13 = Premier League).
    pub league: Option<u32>,
    /// FIFA team id (e.g. 5 = Chelsea). Core calls this "club".
    pub team: Option<u32>,
    /// Client sort token (`desc`). No proven key; dropped.
    pub sort: Option<String>,
    /// Pagination offset.
    pub start: Option<u32>,
    /// Pagination page size.
    pub count: Option<u32>,
}

/// Minimal percent/`+` decoding, dependency-free. FIFA sends bare tokens and
/// numeric ids, but names in general may be percent-encoded.
fn percent_decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let hex = |c: u8| (c as char).to_digit(16);
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'+' => {
                out.push(b' ');
                i += 1;
            }
            b'%' if i + 2 < b.len() => match (hex(b[i + 1]), hex(b[i + 2])) {
                (Some(hi), Some(lo)) => {
                    out.push((hi * 16 + lo) as u8);
                    i += 3;
                }
                _ => {
                    out.push(b'%');
                    i += 1;
                }
            },
            c => {
                out.push(c);
                i += 1;
            }
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// Parse the raw query string into a [`Fifa17ClubQuery`].
///
/// Order-independent by construction (each key sets its own field), so HTTP
/// parameter order can never change the result. Unknown keys (`year`, `type`, …)
/// are ignored. A present-but-unparseable numeric id is treated as absent (the
/// retail client never sends one; absent is the safe, non-amplifying choice).
pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
    let q = query.strip_prefix('?').unwrap_or(query);
    let mut out = Fifa17ClubQuery::default();
    for pair in q.split('&').filter(|p| !p.is_empty()) {
        let (k, v) = match pair.split_once('=') {
            Some((k, v)) => (k, percent_decode(v)),
            None => (pair, String::new()),
        };
        match k {
            "level" => out.level = Some(v),
            "rare" => out.rare = Some(v),
            "position" => out.position = Some(v),
            "nation" => out.nation = v.parse().ok(),
            "league" => out.league = v.parse().ok(),
            "team" => out.team = v.parse().ok(),
            "sort" => out.sort = Some(v),
            "start" => out.start = v.parse().ok(),
            "count" => out.count = v.parse().ok(),
            _ => {}
        }
    }
    out
}

/// Resolves FIFA 17 numeric entity ids to their semantic names. A real
/// implementation reads the game's `leagues`/`teams`/`nations` tables; tests use
/// [`StaticResolver`]. Returning `None` means "unknown id" and is fatal, by
/// design — the raw id must not flow onward.
pub trait EntityResolver {
    fn league_name(&self, id: u32) -> Option<String>;
    fn nation_name(&self, id: u32) -> Option<String>;
    fn team_name(&self, id: u32) -> Option<String>;
}

/// A map-backed [`EntityResolver`] for tests and small deployments.
#[derive(Debug, Default, Clone)]
pub struct StaticResolver {
    pub leagues: HashMap<u32, String>,
    pub nations: HashMap<u32, String>,
    pub teams: HashMap<u32, String>,
}

impl EntityResolver for StaticResolver {
    fn league_name(&self, id: u32) -> Option<String> {
        self.leagues.get(&id).cloned()
    }
    fn nation_name(&self, id: u32) -> Option<String> {
        self.nations.get(&id).cloned()
    }
    fn team_name(&self, id: u32) -> Option<String> {
        self.teams.get(&id).cloned()
    }
}

/// A FIFA id that no resolver could map. Fatal on purpose: never fall back to
/// the raw id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MapError {
    UnknownLeague(u32),
    UnknownNation(u32),
    UnknownTeam(u32),
}

impl std::fmt::Display for MapError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MapError::UnknownLeague(id) => write!(f, "unknown FIFA league id {id}"),
            MapError::UnknownNation(id) => write!(f, "unknown FIFA nation id {id}"),
            MapError::UnknownTeam(id) => write!(f, "unknown FIFA team id {id}"),
        }
    }
}

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

/// The semantic query handed to OpenFUT Core. Contains only game-independent
/// values: a quality tier string, entity **names**, and semantic offset/limit.
/// No FIFA ids.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CoreOwnedQuery {
    pub quality: Option<String>,
    pub position: Option<String>,
    pub nation: Option<String>,
    pub league: Option<String>,
    pub club: Option<String>,
    pub offset: Option<i64>,
    pub limit: Option<i64>,
    /// "Special" filter (`rare=SP`): keep only special cards. Applied by the
    /// HOST via the FIFA `rareflag` (which lives in the catalog, not Core) — so
    /// it is NEVER a Core `/collection` param. See [`is_special_rareflag`].
    pub special: bool,
    /// Wire filters that were parsed but deliberately NOT applied because their
    /// semantics are unproven (currently: `rare`/Special). Recorded, never guessed.
    pub unsupported: Vec<&'static str>,
}

impl CoreOwnedQuery {
    /// Core `/collection` query parameters. Param **names mirror**
    /// `openfut_core::services::inventory::OwnedItemQuery` and are the wire
    /// contract between this adapter and Core (pinned by test). `unsupported`
    /// filters are intentionally absent.
    pub fn to_query_pairs(&self) -> Vec<(&'static str, String)> {
        let mut p = Vec::new();
        if let Some(q) = &self.quality {
            p.push(("quality", q.clone()));
        }
        if let Some(x) = &self.position {
            p.push(("position", x.clone()));
        }
        if let Some(x) = &self.nation {
            p.push(("nation", x.clone()));
        }
        if let Some(x) = &self.league {
            p.push(("league", x.clone()));
        }
        if let Some(x) = &self.club {
            p.push(("club", x.clone()));
        }
        if let Some(x) = self.offset {
            p.push(("offset", x.to_string()));
        }
        if let Some(x) = self.limit {
            p.push(("limit", x.to_string()));
        }
        p
    }
}

/// Map a parsed FIFA 17 query to the semantic Core query, resolving every
/// numeric id to a name. Any unknown id is a hard error — the raw id never flows
/// through.
pub fn map_to_core(
    q: &Fifa17ClubQuery,
    resolver: &impl EntityResolver,
) -> Result<CoreOwnedQuery, MapError> {
    // level: only the proven quality tiers map; "any"/absent → no constraint.
    let quality = match q.level.as_deref() {
        Some("gold") => Some("gold".to_string()),
        Some("silver") => Some("silver".to_string()),
        Some("bronze") => Some("bronze".to_string()),
        _ => None,
    };

    // rare=SP → "Special" quality. Now GROUNDED via the observed FIFA rareflag
    // (carried in the catalog): a special is rareflag > 1 (base rare = 1). The
    // host applies it post-shape; Core never sees it. Any OTHER `rare` value
    // stays genuinely unsupported (recorded, never guessed).
    let special = matches!(q.rare.as_deref(), Some(s) if s.eq_ignore_ascii_case("SP"));
    let mut unsupported = Vec::new();
    if q.rare.is_some() && !special {
        unsupported.push("rare");
    }

    let position = q.position.as_ref().map(|p| p.to_uppercase());

    let nation = match q.nation {
        Some(id) => Some(
            resolver
                .nation_name(id)
                .ok_or(MapError::UnknownNation(id))?,
        ),
        None => None,
    };
    let league = match q.league {
        Some(id) => Some(
            resolver
                .league_name(id)
                .ok_or(MapError::UnknownLeague(id))?,
        ),
        None => None,
    };
    // FIFA "team" is Core "club".
    let club = match q.team {
        Some(id) => Some(resolver.team_name(id).ok_or(MapError::UnknownTeam(id))?),
        None => None,
    };

    Ok(CoreOwnedQuery {
        quality,
        position,
        nation,
        league,
        club,
        offset: q.start.map(|s| s as i64),
        limit: q.count.map(|c| c as i64),
        special,
        unsupported,
    })
}

/// Whether a FIFA `rareflag` denotes a SPECIAL card (in-form/programme), as
/// opposed to a base card. Grounded in the observed profile + the FIFA 17
/// taxonomy: 0 = common, 1 = rare (both BASE gold/silver/bronze); every value
/// above 1 is a special programme (3 = TOTW, 21..=24 = programmes, etc.).
pub fn is_special_rareflag(rareflag: i64) -> bool {
    rareflag > 1
}

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

    fn resolver() -> StaticResolver {
        // Confirmed against fifa17-recon/data/tables/{leagues,teams,nations}.json.
        StaticResolver {
            leagues: HashMap::from([(13, "Premier League".to_string())]),
            nations: HashMap::from([(52, "Argentina".to_string())]),
            teams: HashMap::from([(5, "Chelsea".to_string())]),
        }
    }

    #[test]
    fn parse_full_query() {
        let q = parse_club_query(
            "year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10",
        );
        assert_eq!(
            q,
            Fifa17ClubQuery {
                level: Some("gold".into()),
                rare: None,
                position: Some("ST".into()),
                nation: Some(52),
                league: Some(13),
                team: Some(5),
                sort: Some("desc".into()),
                start: Some(10),
                count: Some(11),
            }
        );
    }

    #[test]
    fn parse_is_parameter_order_independent() {
        let a = parse_club_query("level=gold&league=13&position=ST&start=10&count=11");
        let b = parse_club_query("count=11&start=10&position=ST&league=13&level=gold");
        assert_eq!(a, b);
    }

    #[test]
    fn parse_ignores_unknown_keys_and_omitted_optionals() {
        let q = parse_club_query("year=2017&type=player&level=any&sort=desc");
        assert_eq!(q.level.as_deref(), Some("any"));
        assert!(q.rare.is_none() && q.position.is_none() && q.nation.is_none());
        assert!(q.league.is_none() && q.team.is_none() && q.start.is_none());
    }

    #[test]
    fn parse_percent_encoded_value() {
        let q = parse_club_query("position=ST&rare=SP");
        assert_eq!(q.position.as_deref(), Some("ST"));
        assert_eq!(q.rare.as_deref(), Some("SP"));
    }

    #[test]
    fn parse_malformed_numeric_is_absent() {
        let q = parse_club_query("league=notanumber");
        assert!(
            q.league.is_none(),
            "malformed id treated as absent, not applied"
        );
    }

    #[test]
    fn map_level_gold_to_quality() {
        let core = map_to_core(&parse_club_query("level=gold"), &resolver()).unwrap();
        assert_eq!(core.quality.as_deref(), Some("gold"));
    }

    #[test]
    fn map_level_any_has_no_quality_filter() {
        let core = map_to_core(&parse_club_query("level=any"), &resolver()).unwrap();
        assert_eq!(core.quality, None, "'any' must NOT become a quality filter");
    }

    #[test]
    fn map_rare_sp_sets_special_and_never_a_core_param() {
        let core = map_to_core(&parse_club_query("level=any&rare=SP"), &resolver()).unwrap();
        // rare=SP is now GROUNDED: a host-applied special flag, not "unsupported".
        assert!(core.special, "rare=SP must set the special flag");
        assert!(!core.unsupported.contains(&"rare"));
        // still NEVER a Core predicate (Core has no rareflag) and no quality guess.
        assert_eq!(core.quality, None);
        let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
        assert!(!keys.contains(&"rare") && !keys.contains(&"special"));
    }

    #[test]
    fn map_unknown_rare_value_stays_unsupported() {
        let core = map_to_core(&parse_club_query("rare=WAT"), &resolver()).unwrap();
        assert!(!core.special);
        assert!(core.unsupported.contains(&"rare"));
    }

    #[test]
    fn special_predicate_base_vs_special() {
        assert!(!is_special_rareflag(0)); // common
        assert!(!is_special_rareflag(1)); // rare gold (base)
        assert!(is_special_rareflag(3)); // TOTW
        assert!(is_special_rareflag(24)); // programme
    }

    #[test]
    fn map_resolves_ids_to_semantic_names() {
        let core =
            map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap();
        assert_eq!(core.nation.as_deref(), Some("Argentina"));
        assert_eq!(core.league.as_deref(), Some("Premier League"));
        assert_eq!(
            core.club.as_deref(),
            Some("Chelsea"),
            "FIFA team -> Core club"
        );
    }

    #[test]
    fn map_unknown_id_is_a_hard_error_not_passthrough() {
        assert_eq!(
            map_to_core(&parse_club_query("league=9999"), &resolver()),
            Err(MapError::UnknownLeague(9999))
        );
        assert_eq!(
            map_to_core(&parse_club_query("nation=9999"), &resolver()),
            Err(MapError::UnknownNation(9999))
        );
        assert_eq!(
            map_to_core(&parse_club_query("team=9999"), &resolver()),
            Err(MapError::UnknownTeam(9999))
        );
    }

    #[test]
    fn no_raw_fifa_id_ever_reaches_core() {
        // Every resolvable id becomes a name; a numeric string must never appear
        // as a nation/league/club value in the Core-bound pairs.
        let core =
            map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap();
        for (k, v) in core.to_query_pairs() {
            if matches!(k, "nation" | "league" | "club") {
                assert!(
                    v.parse::<u32>().is_err(),
                    "{k}={v} looks like a raw FIFA id leaking into Core"
                );
            }
        }
    }

    #[test]
    fn map_start_count_to_offset_limit() {
        let core = map_to_core(&parse_club_query("start=20&count=11"), &resolver()).unwrap();
        assert_eq!(core.offset, Some(20));
        assert_eq!(core.limit, Some(11));
    }

    #[test]
    fn map_position_uppercased() {
        let core = map_to_core(&parse_club_query("position=st"), &resolver()).unwrap();
        assert_eq!(core.position.as_deref(), Some("ST"));
    }

    #[test]
    fn sort_is_dropped_no_core_param() {
        let core = map_to_core(&parse_club_query("sort=desc"), &resolver()).unwrap();
        let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
        assert!(
            !keys.contains(&"sort"),
            "no proven FIFA sort key; must not emit one"
        );
    }

    #[test]
    fn core_query_param_names_mirror_core_contract() {
        // Pins the wire contract with openfut-core's OwnedItemQuery field names.
        let core = CoreOwnedQuery {
            quality: Some("gold".into()),
            position: Some("ST".into()),
            nation: Some("Argentina".into()),
            league: Some("Premier League".into()),
            club: Some("Chelsea".into()),
            offset: Some(10),
            limit: Some(11),
            special: false,
            unsupported: vec![],
        };
        let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
        assert_eq!(
            keys,
            ["quality", "position", "nation", "league", "club", "offset", "limit"]
        );
    }

    #[test]
    fn end_to_end_capture_shaped_query() {
        // Mirrors the retail PAGINATION capture: PL + Chelsea, page 2.
        let core = map_to_core(
            &parse_club_query(
                "year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10",
            ),
            &resolver(),
        )
        .unwrap();
        assert_eq!(
            core,
            CoreOwnedQuery {
                quality: Some("gold".into()),
                position: None,
                nation: Some("Argentina".into()),
                league: Some("Premier League".into()),
                club: Some("Chelsea".into()),
                offset: Some(10),
                limit: Some(11),
                special: false,
                unsupported: vec![],
            }
        );
    }
}
