//! Evidence-driven import of a real FIFA 17 Python profile into OpenFUT Core.
//!
//! Read-only analysis (`analyze`) + public/private content emission
//! (`emit_content`). It proves — before anything is written — that the
//! migration can be done faithfully, and then generates exactly the supported
//! definitions. It never fabricates identity, never collapses a versioned card
//! onto its base, and never picks a winner for a conflicted definition.
//!
//! * disjoint item-type accounting must balance to the source total;
//! * every supported player card yields an HONEST [`ObservedDefinition`] keyed
//!   by `fifa17_<resourceId>` (base and versioned are distinct), sourced from
//!   profile evidence: name from the roster, nation/league/club resolved from
//!   the committed tables, quality tier from the rating — never invented, and no
//!   promo/program label guessed (`rare=SP` and TOTW/… stay UNKNOWN);
//! * multiple owned copies of one `resourceId` must agree on definition-level
//!   data — a disagreement is a HARD conflict; a specific conflict may be
//!   *explicitly* deferred (an allowlist), but a NEW one always fails;
//! * un-nameable / unresolved / conflicted definitions are DEFERRED (recorded in
//!   the private manifest with their wire ids), never imported and never given a
//!   placeholder;
//! * every owned instance keeps its existing Python wire id, and the source
//!   allocation watermark (`nextItemId`, next-to-issue) is preserved so a future
//!   Rust-minted id continues past historically burned ids.

use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use anyhow::{Context, Result};

/// FIFA 17 owned-item wire-id floor (adapter `Fifa17WireItemIdPolicy`).
pub const OWNED_ITEM_BASE_FLOOR: i64 = 100_000_001;

pub mod apply;
pub mod model;

use model::{Item, Profile};
use openfut_adapter_fifa17::fut::content_taxonomy::{consumable_family, staff_role, ContentKind};

// ----------------------------------------------------------------- roster

/// Player names keyed by base asset id (`roster.json`). A card whose base asset
/// id is absent here cannot be honestly named and is DEFERRED, never fabricated.
pub struct Roster {
    names: BTreeMap<i64, String>,
}

impl Roster {
    pub fn from_json_str(raw: &str) -> Result<Self> {
        #[derive(serde::Deserialize)]
        struct Row {
            id: i64,
            #[serde(default)]
            first: String,
            #[serde(default)]
            last: String,
            #[serde(default)]
            common: String,
        }
        let rows: Vec<Row> =
            serde_json::from_str(raw).context("parsing roster.json (expected an array)")?;
        let mut names = BTreeMap::new();
        for r in rows {
            let name = if !r.common.trim().is_empty() {
                r.common.trim().to_string()
            } else {
                format!("{} {}", r.first.trim(), r.last.trim())
                    .trim()
                    .to_string()
            };
            if !name.is_empty() {
                names.insert(r.id, name);
            }
        }
        Ok(Roster { names })
    }

    pub fn name_for(&self, asset_id: i64) -> Option<&str> {
        self.names.get(&asset_id).map(String::as_str)
    }
}

pub fn load_roster(path: impl AsRef<Path>) -> Result<Roster> {
    let raw = std::fs::read_to_string(path.as_ref())
        .with_context(|| format!("reading roster {}", path.as_ref().display()))?;
    Roster::from_json_str(&raw)
}

// --------------------------------------------------------------- entities

/// Forward numeric-id -> name resolution for the committed FIFA 17 tables
/// (`leagues.json`/`nations.json`/`teams.json`, `{schema, rows:[…]}` dump).
/// Mirrors `scripts/seed_fifa17_cards.py` (`_table_map`).
#[derive(Default)]
pub struct Entities {
    leagues: BTreeMap<i64, String>,
    nations: BTreeMap<i64, String>,
    teams: BTreeMap<i64, String>,
}

fn load_table(path: &Path, id_key: &str, name_key: &str) -> Result<BTreeMap<i64, String>> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("reading table {}", path.display()))?;
    let doc: serde_json::Value =
        serde_json::from_str(&raw).with_context(|| format!("parsing table {}", path.display()))?;
    let rows = doc
        .get("rows")
        .and_then(|r| r.as_array())
        .with_context(|| format!("table {} has no rows[]", path.display()))?;
    let mut map = BTreeMap::new();
    for row in rows {
        if let (Some(id), Some(name)) = (
            row.get(id_key).and_then(|v| v.as_i64()),
            row.get(name_key).and_then(|v| v.as_str()),
        ) {
            map.insert(id, name.to_string());
        }
    }
    Ok(map)
}

impl Entities {
    pub fn from_tables_dir(dir: impl AsRef<Path>) -> Result<Self> {
        let dir = dir.as_ref();
        Ok(Entities {
            leagues: load_table(&dir.join("leagues.json"), "leagueid", "leaguename")?,
            nations: load_table(&dir.join("nations.json"), "nationid", "nationname")?,
            teams: load_table(&dir.join("teams.json"), "teamid", "teamname")?,
        })
    }

    /// Build directly from id->name maps (tests).
    pub fn from_maps(
        leagues: BTreeMap<i64, String>,
        nations: BTreeMap<i64, String>,
        teams: BTreeMap<i64, String>,
    ) -> Self {
        Entities {
            leagues,
            nations,
            teams,
        }
    }

    pub fn league(&self, id: i64) -> Option<&str> {
        self.leagues.get(&id).map(String::as_str)
    }
    pub fn nation(&self, id: i64) -> Option<&str> {
        self.nations.get(&id).map(String::as_str)
    }
    pub fn club(&self, team_id: i64) -> Option<&str> {
        self.teams.get(&team_id).map(String::as_str)
    }
}

pub fn load_profile(path: impl AsRef<Path>) -> Result<Profile> {
    let raw = std::fs::read_to_string(path.as_ref())
        .with_context(|| format!("reading profile {}", path.as_ref().display()))?;
    Profile::from_json_str(&raw)
}

// ------------------------------------------------------------- classification

/// The disjoint source item classes. Every source item is exactly one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemClass {
    PlayerCard,
    Consumable,
    Staff,
    Other,
}

pub fn classify(item: &Item) -> ItemClass {
    match item.item_type.as_str() {
        "staff" => ItemClass::Staff,
        "player" => {
            if item.attribute_list.is_some() {
                ItemClass::PlayerCard
            } else {
                ItemClass::Consumable
            }
        }
        _ => ItemClass::Other,
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ItemCounts {
    pub total: usize,
    pub player_cards: usize,
    pub consumables: usize,
    pub staff: usize,
    pub other: usize,
}

impl ItemCounts {
    pub fn balances(&self) -> bool {
        self.player_cards + self.consumables + self.staff + self.other == self.total
    }
}

pub fn count_items(profile: &Profile) -> ItemCounts {
    let mut c = ItemCounts {
        total: profile.items.len(),
        ..Default::default()
    };
    for it in &profile.items {
        match classify(it) {
            ItemClass::PlayerCard => c.player_cards += 1,
            ItemClass::Consumable => c.consumables += 1,
            ItemClass::Staff => c.staff += 1,
            ItemClass::Other => c.other += 1,
        }
    }
    c
}

// -------------------------------------------------------------- definitions

/// Definition-level fields carried per owned copy. The DEFINITION IDENTITY
/// subset (`asset_id`/`version`/`rating`/`position`/`attrs`/`rareflag`) MUST
/// agree across copies of one `resourceId`; `nation`/`league`/`team` are
/// observed INSTANCE metadata that MAY legitimately vary per owned copy (a
/// club-affiliation snapshot — evidence: `resourceId` 169193) and are EXCLUDED
/// from the identity gate. Instance fields (wire id, contract, fitness, …) are
/// not modelled here at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefFields {
    pub asset_id: Option<i64>,
    pub version: i64,
    pub rating: Option<i64>,
    pub position: Option<String>,
    pub nation: Option<i64>,
    pub league: Option<i64>,
    pub team: Option<i64>,
    pub attrs: Option<Vec<i64>>,
    pub rareflag: Option<i64>,
}

/// Why a `resourceId` cannot yield an honest CardDefinition (all DEFERRED).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unsupported {
    VersionFormula { resource_id: i64, asset_id: i64 },
    NoName { asset_id: i64 },
    MissingMetadata { fields: Vec<String> },
    UnresolvedEntity { fields: Vec<String> },
}

/// `version = resourceId >> 24`; base cards are version 0 (`resourceId == assetId`).
pub fn version_of(resource_id: i64) -> i64 {
    resource_id >> 24
}

/// Quality tier from a base rating (`fut_cards.py` / `seed_fifa17_cards.py`).
pub fn tier(overall: i64) -> &'static str {
    if overall >= 75 {
        "gold"
    } else if overall >= 65 {
        "silver"
    } else {
        "bronze"
    }
}

/// An honest, profile-derived player CardDefinition proposal, keyed by
/// `fifa17_<resourceId>`. Carries provenance, not marketing taxonomy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservedDefinition {
    pub card_id: String,
    pub resource_id: i64,
    pub asset_id: i64,
    pub version: i64,
    pub name: String,
    pub rating: i64,
    pub position: String,
    pub nation: String,
    pub league: String,
    pub club: String,
    pub nation_id: i64,
    pub league_id: i64,
    pub team_id: i64,
    /// pace, shooting, passing, dribbling, defending, physical (index 0..=5).
    pub attrs: [i64; 6],
    pub rarity: &'static str,
    /// Observed FIFA wire `rareflag` (the card's rare/special TYPE, e.g. 3=inform,
    /// 21..=24 = special programmes). Carried verbatim from the profile — the raw
    /// integer, never a guessed marketing label — so specials render as specials.
    pub rareflag: i64,
    /// Wire ids of the owned copies of this exact resourceId (preserved).
    pub wire_ids: Vec<i64>,
}

/// A deferred definition (unsupported), with its owned wire ids for the manifest.
#[derive(Debug, Clone)]
pub struct DeferredDef {
    pub resource_id: i64,
    pub asset_id: Option<i64>,
    pub wire_ids: Vec<i64>,
    pub reason: Unsupported,
}

/// A `resourceId` whose owned copies disagree on definition-level data.
#[derive(Debug, Clone)]
pub struct DefConflict {
    pub resource_id: i64,
    pub wire_ids: Vec<i64>,
    pub distinct: Vec<DefFields>,
    /// True if this conflict was explicitly approved for deferral (allowlist);
    /// false conflicts are unsafe blockers.
    pub approved_defer: bool,
}

#[derive(Debug, Default)]
pub struct DefinitionPlan {
    pub supported: Vec<ObservedDefinition>,
    pub deferred: Vec<DeferredDef>,
    pub conflicts: Vec<DefConflict>,
    pub base_defs: usize,
    pub versioned_defs: usize,
}

fn def_fields(item: &Item) -> DefFields {
    let attrs = item.attribute_list.as_ref().map(|list| {
        let mut v: Vec<(i64, i64)> = list.iter().map(|a| (a.index, a.value)).collect();
        v.sort_by_key(|(i, _)| *i);
        v.into_iter().map(|(_, val)| val).collect::<Vec<i64>>()
    });
    DefFields {
        asset_id: item.asset_id,
        version: version_of(item.resource_id),
        rating: item.rating,
        position: item.preferred_position.clone(),
        nation: item.nation,
        league: item.league_id,
        team: item.teamid,
        attrs,
        rareflag: item.rareflag,
    }
}

/// The definition-IDENTITY projection of [`DefFields`]: the subset that MUST be
/// identical across every owned copy of one `resourceId`. Deliberately omits
/// `nation`/`league`/`team`, which are observed instance metadata (see
/// [`DefFields`]) — a club-only difference between two copies is NOT a conflict.
#[derive(Debug, Clone, PartialEq, Eq)]
struct DefIdentity {
    asset_id: Option<i64>,
    version: i64,
    rating: Option<i64>,
    position: Option<String>,
    attrs: Option<Vec<i64>>,
    rareflag: Option<i64>,
}

impl DefIdentity {
    fn of(f: &DefFields) -> Self {
        DefIdentity {
            asset_id: f.asset_id,
            version: f.version,
            rating: f.rating,
            position: f.position.clone(),
            attrs: f.attrs.clone(),
            rareflag: f.rareflag,
        }
    }
}

/// Build the definition proposal from player cards, applying the
/// resourceId-group consistency gate, entity resolution, and honest
/// buildability. `approved_conflicts` are the ONLY conflicts allowed to defer.
pub fn plan_definitions(
    profile: &Profile,
    roster: &Roster,
    entities: &Entities,
    approved_conflicts: &BTreeSet<i64>,
) -> DefinitionPlan {
    let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
    for it in &profile.items {
        if classify(it) == ItemClass::PlayerCard {
            groups.entry(it.resource_id).or_default().push(it);
        }
    }

    let mut plan = DefinitionPlan::default();
    for (resource_id, items) in groups {
        let wire_ids: Vec<i64> = items.iter().map(|i| i.id).collect();

        // 1) consistency gate — compare DEFINITION IDENTITY only. A club-only
        // difference (nation/league/team) between copies is observed instance
        // metadata, never a conflict (evidence: resourceId 169193's 4 copies are
        // identical but for club). `distinct` keeps only identity-distinct
        // variants so a real conflict (differing rating/position/attrs/asset)
        // still trips the gate.
        let first = def_fields(items[0]);
        let mut distinct = vec![first.clone()];
        for it in &items[1..] {
            let f = def_fields(it);
            if !distinct
                .iter()
                .any(|d| DefIdentity::of(d) == DefIdentity::of(&f))
            {
                distinct.push(f);
            }
        }
        if distinct.len() > 1 {
            plan.conflicts.push(DefConflict {
                resource_id,
                wire_ids,
                distinct,
                approved_defer: approved_conflicts.contains(&resource_id),
            });
            continue; // never pick a winner
        }

        let f = &first;

        // helper to record a deferral
        macro_rules! defer {
            ($reason:expr) => {{
                plan.deferred.push(DeferredDef {
                    resource_id,
                    asset_id: f.asset_id,
                    wire_ids: wire_ids.clone(),
                    reason: $reason,
                });
                continue;
            }};
        }

        // 2) version formula.
        let Some(asset_id) = f.asset_id else {
            defer!(Unsupported::MissingMetadata {
                fields: vec!["assetId".into()]
            });
        };
        if resource_id != (f.version << 24) | asset_id {
            defer!(Unsupported::VersionFormula {
                resource_id,
                asset_id
            });
        }

        // 3) required metadata present?
        let mut missing = Vec::new();
        if f.rating.is_none() {
            missing.push("rating".into());
        }
        if f.position.is_none() {
            missing.push("position".into());
        }
        if f.nation.is_none() {
            missing.push("nation".into());
        }
        if f.league.is_none() {
            missing.push("league".into());
        }
        if f.team.is_none() {
            missing.push("team".into());
        }
        let attrs6: Option<[i64; 6]> = f.attrs.as_ref().and_then(|a| {
            if a.len() == 6 {
                Some([a[0], a[1], a[2], a[3], a[4], a[5]])
            } else {
                None
            }
        });
        if attrs6.is_none() {
            missing.push("attributeList[6]".into());
        }
        if !missing.is_empty() {
            defer!(Unsupported::MissingMetadata { fields: missing });
        }
        let (rating, position) = (f.rating.unwrap(), f.position.clone().unwrap());
        let (nation_id, league_id, team_id) =
            (f.nation.unwrap(), f.league.unwrap(), f.team.unwrap());

        // 4) name (roster) — evidence, never fabricated.
        let Some(name) = roster.name_for(asset_id) else {
            defer!(Unsupported::NoName { asset_id });
        };

        // 5) entity resolution (nation/league/club names).
        let (Some(nation), Some(league), Some(club)) = (
            entities.nation(nation_id),
            entities.league(league_id),
            entities.club(team_id),
        ) else {
            let mut fields = Vec::new();
            if entities.nation(nation_id).is_none() {
                fields.push(format!("nation:{nation_id}"));
            }
            if entities.league(league_id).is_none() {
                fields.push(format!("league:{league_id}"));
            }
            if entities.club(team_id).is_none() {
                fields.push(format!("team:{team_id}"));
            }
            defer!(Unsupported::UnresolvedEntity { fields });
        };

        plan.supported.push(ObservedDefinition {
            card_id: format!("fifa17_{resource_id}"),
            resource_id,
            asset_id,
            version: f.version,
            name: name.to_string(),
            rating,
            position,
            nation: nation.to_string(),
            league: league.to_string(),
            club: club.to_string(),
            nation_id,
            league_id,
            team_id,
            attrs: attrs6.unwrap(),
            rarity: tier(rating),
            rareflag: f.rareflag.unwrap_or(1),
            wire_ids,
        });
    }
    plan.base_defs = plan.supported.iter().filter(|d| d.version == 0).count();
    plan.versioned_defs = plan.supported.len() - plan.base_defs;
    plan
}

// ------------------------------------------------------- non-player content

/// An honest, profile-derived NON-player CardDefinition proposal (consumable or
/// staff), keyed by `fifa17_<resourceId>`. Neutral player fields are supplied at
/// emit time; this carries only the identity + honest functional `name` (the
/// taxonomy label, never a marketing name).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonPlayerDefinition {
    pub card_id: String,
    pub resource_id: i64,
    /// Base asset id when the source carries one (consumables: `== resource_id`);
    /// staff carry no `assetId`, so this is `None`.
    pub asset_id: Option<i64>,
    pub kind: ContentKind,
    /// FIFA `cardsubtypeid` (consumable family / staff role selector).
    pub subtype: i64,
    /// Honest functional label (e.g. "Player Contract", "GK Coach").
    pub name: String,
    /// Wire ids of every owned copy of this resourceId (preserved).
    pub wire_ids: Vec<i64>,
}

/// A non-player group that cannot be honestly classified (DEFERRED, never
/// fabricated). Mirrors the player NoName gate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeferredNonPlayer {
    pub resource_id: i64,
    /// The agreed subtype when present; `None` when absent or in conflict.
    pub subtype: Option<i64>,
    pub wire_ids: Vec<i64>,
    pub reason: String,
}

#[derive(Debug, Default)]
pub struct NonPlayerPlan {
    pub supported: Vec<NonPlayerDefinition>,
    pub deferred: Vec<DeferredNonPlayer>,
    /// Count of SUPPORTED consumable definitions.
    pub consumables: usize,
    /// Count of SUPPORTED staff definitions.
    pub staff: usize,
}

impl NonPlayerPlan {
    /// Deferred non-player INSTANCES (owned copies) across all deferred groups.
    pub fn deferred_instances(&self) -> usize {
        self.deferred.iter().map(|d| d.wire_ids.len()).sum()
    }
}

/// Plan the non-player (consumable + staff) CardDefinitions. Groups Consumable
/// and Staff items by `resourceId`; each group must agree on `cardsubtypeid`
/// across copies (a disagreement DEFERS with `subtype_conflict`), then resolves
/// the family (consumable) or role (staff) via the adapter's evidence-based
/// taxonomy. A missing or unknown `cardsubtypeid` DEFERS — never a placeholder.
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
    let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
    for it in &profile.items {
        if matches!(classify(it), ItemClass::Consumable | ItemClass::Staff) {
            groups.entry(it.resource_id).or_default().push(it);
        }
    }

    let mut plan = NonPlayerPlan::default();
    for (resource_id, items) in groups {
        let wire_ids: Vec<i64> = items.iter().map(|i| i.id).collect();

        // Class agreement (a resourceId is either all-consumable or all-staff).
        let class = classify(items[0]);
        if items.iter().any(|i| classify(i) != class) {
            plan.deferred.push(DeferredNonPlayer {
                resource_id,
                subtype: None,
                wire_ids,
                reason: "class_conflict".to_string(),
            });
            continue;
        }

        // Subtype must agree across every owned copy (identity invariant).
        let first_subtype = items[0].cardsubtypeid;
        if items.iter().any(|i| i.cardsubtypeid != first_subtype) {
            plan.deferred.push(DeferredNonPlayer {
                resource_id,
                subtype: None,
                wire_ids,
                reason: "subtype_conflict".to_string(),
            });
            continue;
        }
        let Some(subtype) = first_subtype else {
            plan.deferred.push(DeferredNonPlayer {
                resource_id,
                subtype: None,
                wire_ids,
                reason: "missing_cardsubtypeid".to_string(),
            });
            continue;
        };

        let (kind, label) = match class {
            ItemClass::Consumable => match consumable_family(subtype) {
                Some((_family, label)) => (ContentKind::Consumable, label),
                None => {
                    plan.deferred.push(DeferredNonPlayer {
                        resource_id,
                        subtype: Some(subtype),
                        wire_ids,
                        reason: "unknown_subtype".to_string(),
                    });
                    continue;
                }
            },
            ItemClass::Staff => match staff_role(subtype) {
                Some((_role, label)) => (ContentKind::Staff, label),
                None => {
                    plan.deferred.push(DeferredNonPlayer {
                        resource_id,
                        subtype: Some(subtype),
                        wire_ids,
                        reason: "unknown_subtype".to_string(),
                    });
                    continue;
                }
            },
            _ => unreachable!("only Consumable/Staff were grouped"),
        };

        plan.supported.push(NonPlayerDefinition {
            card_id: format!("fifa17_{resource_id}"),
            resource_id,
            asset_id: items[0].asset_id,
            kind,
            subtype,
            name: label.to_string(),
            wire_ids,
        });
    }
    plan.consumables = plan
        .supported
        .iter()
        .filter(|d| d.kind == ContentKind::Consumable)
        .count();
    plan.staff = plan
        .supported
        .iter()
        .filter(|d| d.kind == ContentKind::Staff)
        .count();
    plan
}

// --------------------------------------------------------------- identity

#[derive(Debug, Default)]
pub struct IdentityPlan {
    pub import_wire_ids: Vec<i64>,
    pub duplicate_wire_ids: Vec<i64>,
    pub live_min: Option<i64>,
    pub live_max: Option<i64>,
    pub source_watermark: i64,
    pub next_allocation: i64,
}

pub fn plan_identity(profile: &Profile, supported_resource_ids: &BTreeSet<i64>) -> IdentityPlan {
    let mut seen = BTreeSet::new();
    let mut dups = BTreeSet::new();
    for it in &profile.items {
        if !seen.insert(it.id) {
            dups.insert(it.id);
        }
    }
    let mut import_wire_ids: Vec<i64> = profile
        .items
        .iter()
        .filter(|it| {
            classify(it) == ItemClass::PlayerCard
                && supported_resource_ids.contains(&it.resource_id)
        })
        .map(|it| it.id)
        .collect();
    import_wire_ids.sort_unstable();

    let live_min = profile.items.iter().map(|i| i.id).min();
    let live_max = profile.items.iter().map(|i| i.id).max();
    let watermark = profile.next_item_id;
    let next_allocation = [
        OWNED_ITEM_BASE_FLOOR,
        live_max.map(|m| m + 1).unwrap_or(OWNED_ITEM_BASE_FLOOR),
        watermark,
    ]
    .into_iter()
    .max()
    .unwrap();

    IdentityPlan {
        import_wire_ids,
        duplicate_wire_ids: dups.into_iter().collect(),
        live_min,
        live_max,
        source_watermark: watermark,
        next_allocation,
    }
}

// ----------------------------------------------------------------- squad

#[derive(Debug, Default)]
pub struct SquadCoverage {
    pub present: bool,
    pub formation: String,
    pub occupied_slots: usize,
    pub supported_slots: usize,
    pub unsupported_slots: Vec<(i64, i64)>,
    pub manager_wire_ids: Vec<i64>,
    pub captain_wire_id: Option<i64>,
}

pub fn plan_squad(profile: &Profile, supported_wire_ids: &BTreeSet<i64>) -> SquadCoverage {
    let Some(sq) = profile.squads.first() else {
        return SquadCoverage::default();
    };
    let mut cov = SquadCoverage {
        present: true,
        formation: sq.formation.clone(),
        captain_wire_id: sq.captain.filter(|&c| c != 0),
        manager_wire_ids: sq
            .manager
            .iter()
            .filter_map(|m| m.get("id").and_then(|v| v.as_i64()))
            .filter(|&id| id != 0)
            .collect(),
        ..Default::default()
    };
    for slot in &sq.players {
        let wid = slot.item_data.id;
        if wid == 0 {
            continue;
        }
        cov.occupied_slots += 1;
        if supported_wire_ids.contains(&wid) {
            cov.supported_slots += 1;
        } else {
            cov.unsupported_slots.push((slot.index, wid));
        }
    }
    cov
}

// ----------------------------------------------------------------- report

#[derive(Debug)]
pub struct Report {
    pub game: String,
    pub persona_id: i64,
    pub persona_name: String,
    pub club_name: String,
    pub club_abbr: String,
    pub coins: i64,
    pub counts: ItemCounts,
    pub definitions: DefinitionPlan,
    pub identity: IdentityPlan,
    pub squad: SquadCoverage,
    /// Consumable + staff content (supported definitions + deferred groups).
    pub non_player: NonPlayerPlan,
    /// Unconsumed pack entitlements to seed (from `unopenedPackIds`).
    pub unopened_pack_ids: Vec<i64>,
}

impl Report {
    /// Count of deferred player INSTANCES (owned copies), across unsupported
    /// definitions and (approved-)deferred conflicts.
    pub fn deferred_instances(&self) -> usize {
        let d: usize = self
            .definitions
            .deferred
            .iter()
            .map(|d| d.wire_ids.len())
            .sum();
        let c: usize = self
            .definitions
            .conflicts
            .iter()
            .map(|c| c.wire_ids.len())
            .sum();
        d + c
    }

    /// Unsafe conditions that MUST fail (nonzero). Explicitly-deferred conflicts,
    /// NoName/unresolved/version deferrals are NOT here — they are recorded.
    pub fn blockers(&self) -> Vec<String> {
        let mut b = Vec::new();
        if !self.counts.balances() {
            b.push(format!(
                "item-type accounting does not balance ({}+{}+{}+{} != {})",
                self.counts.player_cards,
                self.counts.consumables,
                self.counts.staff,
                self.counts.other,
                self.counts.total
            ));
        }
        if self.counts.other > 0 {
            b.push(format!("{} item(s) in no known class", self.counts.other));
        }
        let new_conflicts = self
            .definitions
            .conflicts
            .iter()
            .filter(|c| !c.approved_defer)
            .count();
        if new_conflicts > 0 {
            b.push(format!(
                "{new_conflicts} NEW (unapproved) resourceId conflict(s) — pass --defer-conflict <rid> only for a reviewed one"
            ));
        }
        if !self.identity.duplicate_wire_ids.is_empty() {
            b.push(format!(
                "{} duplicate owned wire id(s)",
                self.identity.duplicate_wire_ids.len()
            ));
        }
        if self.squad.present && !self.squad.unsupported_slots.is_empty() {
            b.push(format!(
                "{} active-squad slot(s) reference an unsupported/missing item",
                self.squad.unsupported_slots.len()
            ));
        }
        b
    }

    pub fn has_blockers(&self) -> bool {
        !self.blockers().is_empty()
    }
}

/// Run the full read-only analysis. Pure: no I/O, no writes.
pub fn analyze(
    profile: &Profile,
    roster: &Roster,
    entities: &Entities,
    approved_conflicts: &BTreeSet<i64>,
) -> Report {
    let counts = count_items(profile);
    let definitions = plan_definitions(profile, roster, entities, approved_conflicts);
    let supported_rids: BTreeSet<i64> = definitions
        .supported
        .iter()
        .map(|d| d.resource_id)
        .collect();
    let identity = plan_identity(profile, &supported_rids);
    let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
    let squad = plan_squad(profile, &supported_wire);
    let non_player = plan_non_player_definitions(profile);
    Report {
        game: "fifa17".to_string(),
        persona_id: profile.persona_id,
        persona_name: profile.persona_name.clone(),
        club_name: profile.club_name.clone(),
        club_abbr: profile.club_abbr.clone(),
        coins: profile.coins,
        counts,
        definitions,
        identity,
        squad,
        non_player,
        unopened_pack_ids: profile.unopened_pack_ids.clone(),
    }
}

impl std::fmt::Display for Report {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let d = &self.definitions;
        let id = &self.identity;
        let sq = &self.squad;
        let np = &self.non_player;
        writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?;
        writeln!(f, "=============================================")?;
        writeln!(
            f,
            "TARGET  game={} persona={}/{} club={}/{} coins={}",
            self.game,
            self.persona_id,
            self.persona_name,
            self.club_name,
            self.club_abbr,
            self.coins
        )?;
        writeln!(
            f,
            "\nSOURCE ITEMS  total={} player_cards={} consumables={} staff={} other={} balances={}",
            self.counts.total,
            self.counts.player_cards,
            self.counts.consumables,
            self.counts.staff,
            self.counts.other,
            self.counts.balances()
        )?;
        writeln!(
            f,
            "\nPLAYER DEFINITIONS (fifa17_<resourceId>)  supported={} (base={} versioned={}) deferred_defs={} conflicts={}",
            d.supported.len(),
            d.base_defs,
            d.versioned_defs,
            d.deferred.len(),
            d.conflicts.len()
        )?;
        for dd in &d.deferred {
            writeln!(
                f,
                "    DEFER resourceId={} copies={} {:?}",
                dd.resource_id,
                dd.wire_ids.len(),
                dd.reason
            )?;
        }
        for c in &d.conflicts {
            writeln!(
                f,
                "    CONFLICT resourceId={} copies={} distinct={} approved_defer={}",
                c.resource_id,
                c.wire_ids.len(),
                c.distinct.len(),
                c.approved_defer
            )?;
        }
        writeln!(
            f,
            "\nIDENTITY  import_instances={} live=[{:?}..{:?}] duplicates={} watermark={} next_alloc={}",
            id.import_wire_ids.len(),
            id.live_min,
            id.live_max,
            id.duplicate_wire_ids.len(),
            id.source_watermark,
            id.next_allocation
        )?;
        if sq.present {
            writeln!(
                f,
                "\nACTIVE SQUAD  formation={} occupied={} supported={} unsupported={} captain={:?} manager={:?}",
                sq.formation,
                sq.occupied_slots,
                sq.supported_slots,
                sq.unsupported_slots.len(),
                sq.captain_wire_id,
                sq.manager_wire_ids
            )?;
            for (idx, wid) in &sq.unsupported_slots {
                writeln!(f, "    UNSUPPORTED slot index={idx} wire_id={wid}")?;
            }
        } else {
            writeln!(f, "\nACTIVE SQUAD  (none)")?;
        }
        writeln!(
            f,
            "\nNON-PLAYER CONTENT (consumable/staff)  supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}",
            np.supported.len(),
            np.consumables,
            np.staff,
            np.deferred.len(),
            np.deferred_instances()
        )?;
        for nd in &np.deferred {
            writeln!(
                f,
                "    DEFER resourceId={} subtype={:?} copies={} reason={}",
                nd.resource_id,
                nd.subtype,
                nd.wire_ids.len(),
                nd.reason
            )?;
        }
        writeln!(
            f,
            "\nRESULT  would_import_players={} would_import_non_players={} deferred_player_instances={} deferred_non_player_instances={}",
            id.import_wire_ids.len(),
            np.supported.iter().map(|d| d.wire_ids.len()).sum::<usize>(),
            self.deferred_instances(),
            np.deferred_instances()
        )?;
        let blockers = self.blockers();
        if blockers.is_empty() {
            writeln!(f, "  BLOCKERS: none")?;
        } else {
            writeln!(f, "  BLOCKERS ({}):", blockers.len())?;
            for b in &blockers {
                writeln!(f, "    - {b}")?;
            }
        }
        Ok(())
    }
}

// ----------------------------------------------------------------- emit

/// Dependency-free 64-bit FNV-1a hex fingerprint of the source snapshot (a
/// provenance handle, not a cryptographic digest).
pub fn fingerprint(bytes: &[u8]) -> String {
    let mut h: u64 = 0xcbf29ce484222325;
    for &b in bytes {
        h ^= b as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    format!("{h:016x}")
}

/// What `emit_content` wrote.
#[derive(Debug)]
pub struct EmitSummary {
    pub content_pack: std::path::PathBuf,
    pub host_catalog: std::path::PathBuf,
    pub manifest: std::path::PathBuf,
    pub definitions: usize,
    pub catalog_entries: usize,
    pub supported_instances: usize,
    pub deferred_instances: usize,
    /// Non-player (consumable/staff) supported definitions written.
    pub non_player_definitions: usize,
    /// Non-player supported owned INSTANCES (owned copies across those defs).
    pub non_player_instances: usize,
}

/// Emit the PUBLIC content pack + host catalog for supported definitions, and a
/// PRIVATE import manifest (wire ids + deferred set + watermark + target). Never
/// writes Core or the identity store. Refuses if the report has blockers.
pub fn emit_content(
    report: &Report,
    out_dir: &Path,
    snapshot_fingerprint: &str,
) -> Result<EmitSummary> {
    if report.has_blockers() {
        anyhow::bail!("refusing to emit content while blockers are present (resolve them first)");
    }
    let content_dir = out_dir.join("content");
    let manifest_dir = out_dir.join("manifest");
    std::fs::create_dir_all(&content_dir)?;
    std::fs::create_dir_all(&manifest_dir)?;

    // ---- PUBLIC: Core CardDefinition[] (matches openfut-core models::card) ----
    let mut defs: Vec<serde_json::Value> = report
        .definitions
        .supported
        .iter()
        .map(|d| {
            serde_json::json!({
                "id": d.card_id,
                "name": d.name,
                "overall": d.rating,
                "position": d.position,
                "nation": d.nation,
                "league": d.league,
                "club": d.club,
                "pace": d.attrs[0],
                "shooting": d.attrs[1],
                "passing": d.attrs[2],
                "dribbling": d.attrs[3],
                "defending": d.attrs[4],
                "physical": d.attrs[5],
                "rarity": d.rarity,
                "image_path": serde_json::Value::Null,
            })
        })
        .collect();
    // Non-player CardDefinitions use NEUTRAL player fields + the honest family/
    // role name; Core stores them like any other definition (no FIFA concept).
    for d in &report.non_player.supported {
        defs.push(serde_json::json!({
            "id": d.card_id,
            "name": d.name,
            "overall": 0,
            "position": "",
            "nation": "",
            "league": "",
            "club": "",
            "pace": 0,
            "shooting": 0,
            "passing": 0,
            "dribbling": 0,
            "defending": 0,
            "physical": 0,
            "rarity": "bronze",
            "image_path": serde_json::Value::Null,
        }));
    }
    let content_pack = content_dir.join("fifa17-production-cards.json");
    write_json_pretty(&content_pack, &defs)?;

    // ---- PUBLIC: host identity catalog {card_id: {asset_id, version, rareflag}} ----
    let mut cards = serde_json::Map::new();
    for d in &report.definitions.supported {
        // Players carry an explicit kind:"player" + subtype:0 so the adapter can
        // classify EVERY catalogued card (not just non-players).
        cards.insert(
            d.card_id.clone(),
            serde_json::json!({
                "asset_id": d.asset_id,
                "version": d.version,
                "rareflag": d.rareflag,
                "kind": "player",
                "subtype": 0,
            }),
        );
    }
    for d in &report.non_player.supported {
        // asset_id falls back to resource_id (staff carry no assetId); version 0,
        // rareflag 0 — a consumable/staff never renders as a special card.
        cards.insert(
            d.card_id.clone(),
            serde_json::json!({
                "asset_id": d.asset_id.unwrap_or(d.resource_id),
                "version": 0,
                "rareflag": 0,
                "kind": d.kind.as_str(),
                "subtype": d.subtype,
            }),
        );
    }
    let catalog = serde_json::json!({
        "schema_version": 1,
        "game": "fifa17",
        "_provenance": {
            "generator": "openfut-import-fifa17",
            "source_kind": "python-profile-observation",
            "snapshot_fingerprint": snapshot_fingerprint,
            "base_cards_only": false,
        },
        "cards": cards,
    });
    let host_catalog = content_dir.join("fifa17-production-catalog.json");
    write_json_pretty(&host_catalog, &catalog)?;

    // ---- PRIVATE: import manifest (wire ids + deferred set) ----
    let supported: Vec<serde_json::Value> = report
        .definitions
        .supported
        .iter()
        .map(|d| {
            serde_json::json!({
                "card_id": d.card_id,
                "resource_id": d.resource_id,
                "asset_id": d.asset_id,
                "version": d.version,
                "wire_ids": d.wire_ids,
            })
        })
        .collect();
    let mut deferred: Vec<serde_json::Value> = report
        .definitions
        .deferred
        .iter()
        .map(|dd| {
            serde_json::json!({
                "resource_id": dd.resource_id,
                "asset_id": dd.asset_id,
                "wire_ids": dd.wire_ids,
                "reason": format!("{:?}", dd.reason),
            })
        })
        .collect();
    for c in &report.definitions.conflicts {
        deferred.push(serde_json::json!({
            "resource_id": c.resource_id,
            "wire_ids": c.wire_ids,
            "reason": "definition_metadata_conflict",
            "approved_defer": c.approved_defer,
            "distinct_variants": c.distinct.len(),
        }));
    }
    let non_player_supported: Vec<serde_json::Value> = report
        .non_player
        .supported
        .iter()
        .map(|d| {
            serde_json::json!({
                "card_id": d.card_id,
                "resource_id": d.resource_id,
                "asset_id": d.asset_id,
                "kind": d.kind.as_str(),
                "subtype": d.subtype,
                "name": d.name,
                "wire_ids": d.wire_ids,
            })
        })
        .collect();
    let non_player_deferred: Vec<serde_json::Value> = report
        .non_player
        .deferred
        .iter()
        .map(|dd| {
            serde_json::json!({
                "resource_id": dd.resource_id,
                "subtype": dd.subtype,
                "wire_ids": dd.wire_ids,
                "reason": dd.reason,
            })
        })
        .collect();
    let supported_instances = report.identity.import_wire_ids.len();
    let deferred_instances = report.deferred_instances();
    let non_player_definitions = report.non_player.supported.len();
    let non_player_instances: usize = report
        .non_player
        .supported
        .iter()
        .map(|d| d.wire_ids.len())
        .sum();
    let manifest = serde_json::json!({
        "generator": "openfut-import-fifa17",
        "source_kind": "python-profile-observation",
        "snapshot_fingerprint": snapshot_fingerprint,
        "target": {
            "game": report.game,
            "persona_id": report.persona_id,
            "persona_name": report.persona_name,
            "club_name": report.club_name,
            "club_abbr": report.club_abbr,
        },
        "identity": {
            "base_floor": OWNED_ITEM_BASE_FLOOR,
            "source_watermark": report.identity.source_watermark,
            "next_allocation": report.identity.next_allocation,
            "supported_instances": supported_instances,
        },
        "supported_definitions": supported,
        "deferred": deferred,
        "non_player": {
            "supported_definitions": non_player_supported,
            "supported_instances": non_player_instances,
            "deferred": non_player_deferred,
            "deferred_instances": report.non_player.deferred_instances(),
        },
    });
    let manifest_path = manifest_dir.join("fifa17-import-manifest.json");
    write_json_pretty(&manifest_path, &manifest)?;

    Ok(EmitSummary {
        content_pack,
        host_catalog,
        manifest: manifest_path,
        definitions: report.definitions.supported.len(),
        catalog_entries: report.definitions.supported.len() + non_player_definitions,
        supported_instances,
        deferred_instances,
        non_player_definitions,
        non_player_instances,
    })
}

fn write_json_pretty<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
    let json = serde_json::to_vec_pretty(value)?;
    std::fs::write(path, &json).with_context(|| format!("writing {}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests;
