use super::*;
use model::Profile;
use std::collections::{BTreeMap, BTreeSet};

const VER5_176580: i64 = (5 << 24) | 176580; // versioned resourceId for asset 176580

fn roster() -> Roster {
    Roster::from_json_str(
        r#"[
            {"id":20801,"first":"Cristiano","last":"Ronaldo","common":""},
            {"id":176580,"first":"Luis","last":"Suárez","common":""},
            {"id":158023,"first":"Lionel","last":"Messi","common":""}
        ]"#,
    )
    .unwrap()
}

fn entities() -> Entities {
    Entities::from_maps(
        BTreeMap::from([(53, "LaLiga".to_string()), (16, "Serie A".to_string())]),
        BTreeMap::from([(38, "Portugal".to_string()), (45, "Spain".to_string())]),
        BTreeMap::from([
            (243, "Real Madrid".to_string()),
            (21, "Juventus".to_string()),
        ]),
    )
}

fn none() -> BTreeSet<i64> {
    BTreeSet::new()
}

fn attrs() -> String {
    r#"[{"index":0,"value":90},{"index":1,"value":91},{"index":2,"value":82},
        {"index":3,"value":88},{"index":4,"value":30},{"index":5,"value":78}]"#
        .to_string()
}

/// A resolvable player (nation 38, team 243, league 53 all in `entities()`).
fn player(id: i64, resource: i64, asset: i64, rating: i64) -> String {
    format!(
        r#"{{"id":{id},"resourceId":{resource},"assetId":{asset},"itemType":"player",
            "rareflag":1,"rating":{rating},"preferredPosition":"ST","nation":38,
            "teamid":243,"leagueId":53,"attributeList":{}}}"#,
        attrs()
    )
}

fn profile(items: &[String], squads: &str, next_item_id: i64) -> Profile {
    let json = format!(
        r#"{{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC",
             "coins":1000,"nextItemId":{next_item_id},"items":[{}],"squads":{}}}"#,
        items.join(","),
        squads
    );
    Profile::from_json_str(&json).unwrap()
}

fn defs(items: &[String]) -> DefinitionPlan {
    plan_definitions(
        &profile(items, "[]", 100000500),
        &roster(),
        &entities(),
        &none(),
    )
}

#[test]
fn classification_balances_across_disjoint_classes() {
    let items = vec![
        player(100000001, 20801, 20801, 94),
        player(100000002, VER5_176580, 176580, 92),
        r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
        r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
    ];
    let c = count_items(&profile(&items, "[]", 100000500));
    assert_eq!(
        (c.total, c.player_cards, c.consumables, c.staff, c.other),
        (4, 2, 1, 1, 0)
    );
    assert!(c.balances());
}

#[test]
fn version_formula_card_id_and_resolved_names() {
    assert_eq!(version_of(20801), 0);
    assert_eq!(version_of(VER5_176580), 5);
    let plan = defs(&[player(100000002, VER5_176580, 176580, 92)]);
    assert_eq!(plan.supported.len(), 1);
    let d = &plan.supported[0];
    assert_eq!(d.card_id, format!("fifa17_{VER5_176580}"));
    assert_eq!(d.version, 5);
    assert_eq!(d.asset_id, 176580);
    assert_eq!(d.name, "Luis Suárez"); // base-asset name, never fabricated
    assert_eq!(
        (d.nation.as_str(), d.league.as_str(), d.club.as_str()),
        ("Portugal", "LaLiga", "Real Madrid")
    );
    assert_eq!(d.rarity, "gold"); // rating 92 -> tier, NOT a promo label
}

#[test]
fn base_and_versioned_are_distinct_definitions() {
    let plan = defs(&[
        player(100000010, 176580, 176580, 92),
        player(100000011, VER5_176580, 176580, 92),
    ]);
    assert_eq!(plan.supported.len(), 2, "base and special never collapse");
    assert_eq!((plan.base_defs, plan.versioned_defs), (1, 1));
}

#[test]
fn duplicate_copies_share_definition_but_keep_distinct_wire_ids() {
    let plan = defs(&[
        player(100000001, 20801, 20801, 94),
        player(100000015, 20801, 20801, 94),
    ]);
    assert_eq!(plan.supported.len(), 1);
    assert_eq!(plan.supported[0].wire_ids, vec![100000001, 100000015]);
}

#[test]
fn new_conflict_blocks_but_approved_conflict_defers() {
    let items = vec![
        player(100000001, 20801, 20801, 94),
        player(100000002, 20801, 20801, 93), // disagree on rating
    ];
    // unapproved: a hard blocker
    let rep = analyze(
        &profile(&items, "[]", 100000500),
        &roster(),
        &entities(),
        &none(),
    );
    assert!(rep.definitions.supported.is_empty());
    assert_eq!(rep.definitions.conflicts.len(), 1);
    assert!(!rep.definitions.conflicts[0].approved_defer);
    assert!(rep.has_blockers(), "a NEW conflict must block");
    // approved: deferred, not blocking
    let ok = analyze(
        &profile(&items, "[]", 100000500),
        &roster(),
        &entities(),
        &BTreeSet::from([20801]),
    );
    assert!(ok.definitions.conflicts[0].approved_defer);
    assert!(
        !ok.has_blockers(),
        "an approved-deferred conflict does not block"
    );
    assert_eq!(
        ok.deferred_instances(),
        2,
        "both copies counted as deferred"
    );
}

#[test]
fn version_formula_violation_defers_not_fabricates() {
    let plan = defs(&[player(100000003, 999, 176580, 92)]); // 999 != (0<<24)|176580
    assert!(plan.supported.is_empty());
    assert!(matches!(
        plan.deferred.first().map(|d| &d.reason),
        Some(Unsupported::VersionFormula { .. })
    ));
}

#[test]
fn missing_roster_name_defers_never_faked() {
    let plan = defs(&[player(100000004, 777777, 777777, 80)]); // 777777 not in roster
    assert!(plan.supported.is_empty());
    assert!(matches!(
        plan.deferred.first().map(|d| &d.reason),
        Some(Unsupported::NoName { asset_id: 777777 })
    ));
}

#[test]
fn unresolved_entity_defers() {
    // nation 999 not in entities()
    let item = format!(
        r#"{{"id":100000005,"resourceId":20801,"assetId":20801,"itemType":"player","rareflag":1,
            "rating":94,"preferredPosition":"ST","nation":999,"teamid":243,"leagueId":53,
            "attributeList":{}}}"#,
        attrs()
    );
    let plan = defs(&[item]);
    assert!(plan.supported.is_empty());
    assert!(matches!(
        plan.deferred.first().map(|d| &d.reason),
        Some(Unsupported::UnresolvedEntity { .. })
    ));
}

#[test]
fn identity_preserves_watermark_over_live_max() {
    let items = vec![
        player(100000001, 20801, 20801, 94),
        player(100004605, 176580, 176580, 92),
    ];
    let idp = plan_identity(
        &profile(&items, "[]", 100004617),
        &BTreeSet::from([20801, 176580]),
    );
    assert_eq!(
        (idp.live_min, idp.live_max),
        (Some(100000001), Some(100004605))
    );
    assert_eq!(idp.source_watermark, 100004617);
    assert_eq!(
        idp.next_allocation, 100004617,
        "next-to-issue, not live_max+1"
    );
}

#[test]
fn squad_coverage_flags_unsupported_starter_and_keeps_manager() {
    let items = vec![
        player(100000001, 20801, 20801, 94),
        player(100000002, 176580, 176580, 92),
    ];
    let squads = r#"[{
        "formation":"f433","squadName":"OpenFUT","captain":100000001,
        "manager":[{"id":100000427,"dream":false}],
        "players":[
            {"index":0,"itemData":{"id":100000001},"kitNumber":7},
            {"index":1,"itemData":{"id":999999999},"kitNumber":9},
            {"index":2,"itemData":{"id":0},"kitNumber":0}
        ]}]"#;
    let rep = analyze(
        &profile(&items, squads, 100000500),
        &roster(),
        &entities(),
        &none(),
    );
    let sq = &rep.squad;
    assert_eq!((sq.occupied_slots, sq.supported_slots), (2, 1));
    assert_eq!(sq.unsupported_slots, vec![(1, 999999999)]);
    assert_eq!(sq.manager_wire_ids, vec![100000427]);
    assert_eq!(sq.captain_wire_id, Some(100000001));
    assert!(rep.has_blockers());
}

#[test]
fn analyze_is_deterministic() {
    let items = vec![
        player(100000002, VER5_176580, 176580, 92),
        player(100000001, 20801, 20801, 94),
    ];
    let p = profile(&items, "[]", 100000500);
    let a = analyze(&p, &roster(), &entities(), &none());
    let b = analyze(&p, &roster(), &entities(), &none());
    assert_eq!(a.definitions.supported, b.definitions.supported);
    assert_eq!(a.identity.import_wire_ids, b.identity.import_wire_ids);
}

#[test]
fn emit_content_writes_public_pack_catalog_and_private_manifest() {
    let items = vec![
        player(100000001, 20801, 20801, 94),
        player(100000002, VER5_176580, 176580, 92),
        r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
        r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
    ];
    let squads = r#"[{"formation":"f433","squadName":"OpenFUT","captain":100000001,
        "manager":[{"id":100000427}],
        "players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":100000002}}]}]"#;
    let p = profile(&items, squads, 100000500);
    let rep = analyze(&p, &roster(), &entities(), &none());
    assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());

    let dir = tempfile::tempdir().unwrap();
    let sum = emit_content(&rep, dir.path(), "deadbeef").unwrap();
    assert_eq!(sum.definitions, 2);
    assert_eq!(sum.supported_instances, 2);
    assert_eq!(sum.deferred_instances, 0);
    assert!(sum.content_pack.exists());
    assert!(sum.host_catalog.exists());
    assert!(sum.manifest.exists());

    // content pack is a Core CardDefinition[] with our card_id + resolved names
    let pack: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap();
    let ids: Vec<&str> = pack
        .as_array()
        .unwrap()
        .iter()
        .map(|c| c["id"].as_str().unwrap())
        .collect();
    assert!(ids.contains(&"fifa17_20801"));
    assert!(ids.contains(&format!("fifa17_{VER5_176580}").as_str()));

    // catalog carries versioned entries
    let cat: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap();
    assert_eq!(cat["cards"][format!("fifa17_{VER5_176580}")]["version"], 5);
    assert_eq!(cat["cards"]["fifa17_20801"]["version"], 0);

    // manifest preserves wire ids and is separate from public content
    let man: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
    assert_eq!(man["identity"]["source_watermark"], 100000500);
    assert_eq!(man["target"]["persona_id"], 33068179);
}

#[test]
fn emit_refuses_when_blocked() {
    let items = vec![
        player(100000001, 20801, 20801, 94),
        player(100000002, 20801, 20801, 93), // unapproved conflict -> blocker
    ];
    let rep = analyze(
        &profile(&items, "[]", 100000500),
        &roster(),
        &entities(),
        &none(),
    );
    let dir = tempfile::tempdir().unwrap();
    assert!(emit_content(&rep, dir.path(), "x").is_err());
}

#[test]
fn club_only_difference_across_copies_is_not_a_conflict() {
    // Two owned copies of one resourceId, identical in asset/rating/position/
    // attributes but DIFFERENT nation/team/league (a club-affiliation snapshot).
    // Evidence (resourceId 169193) says this is NOT a conflict: one definition,
    // both preserved wire ids, display club from the first-observed copy.
    let a = format!(
        r#"{{"id":100000001,"resourceId":20801,"assetId":20801,"itemType":"player","rareflag":1,
            "rating":94,"preferredPosition":"ST","nation":38,"teamid":243,"leagueId":53,"attributeList":{}}}"#,
        attrs()
    );
    let b = format!(
        r#"{{"id":100000002,"resourceId":20801,"assetId":20801,"itemType":"player","rareflag":1,
            "rating":94,"preferredPosition":"ST","nation":45,"teamid":21,"leagueId":16,"attributeList":{}}}"#,
        attrs()
    );
    let plan = defs(&[a, b]);
    assert!(
        plan.conflicts.is_empty(),
        "a club-only difference must NOT be a conflict"
    );
    assert_eq!(plan.supported.len(), 1, "one definition");
    let d = &plan.supported[0];
    assert_eq!(
        d.wire_ids,
        vec![100000001, 100000002],
        "both copies preserved"
    );
    assert_eq!(d.nation, "Portugal", "display club = first observed copy");
    assert_eq!(d.club, "Real Madrid");
    assert_eq!(d.league, "LaLiga");
}

#[test]
fn identity_difference_across_copies_is_still_a_conflict() {
    // A differing rating is a real definition-identity disagreement -> conflict,
    // never silently merged.
    let plan = defs(&[
        player(100000001, 20801, 20801, 94),
        player(100000002, 20801, 20801, 90),
    ]);
    assert!(plan.supported.is_empty());
    assert_eq!(plan.conflicts.len(), 1);
}

// ------------------------------------------------------------------ apply

use crate::apply::{
    apply as apply_import, content_card_ids, gate_staging, identity_dry_preflight,
    local_core_preflight, owned_item_id, plan_apply, post_validate_identity, seed_identity,
};
use openfut_identity::{ExternalIdentityStore, JsonIdentityStore};

const SQUAD_F433: &str = r#"[{"formation":"f433","squadName":"OpenFUT","captain":100000001,
    "squadType":"REGULAR_SQUAD","custom":"[0,0,0]",
    "players":[{"index":0,"itemData":{"id":100000001},"kitNumber":7},
               {"index":1,"itemData":{"id":100000002},"kitNumber":9}],
    "kicktakers":[{"index":0,"id":100000001}],"manager":[{"id":100000427}]}]"#;

/// Build a report + raw profile Value from the same JSON (typed profile for
/// analysis, raw Value for the squad wire).
fn report_and_raw(
    items: &[String],
    squads: &str,
    next_item_id: i64,
) -> (Report, serde_json::Value) {
    let json = format!(
        r#"{{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC",
             "coins":1000,"nextItemId":{next_item_id},"items":[{}],"squads":{}}}"#,
        items.join(","),
        squads
    );
    let prof = Profile::from_json_str(&json).unwrap();
    let report = analyze(&prof, &roster(), &entities(), &none());
    let raw: serde_json::Value = serde_json::from_str(&json).unwrap();
    (report, raw)
}

#[test]
fn owned_item_id_is_deterministic_and_distinct() {
    let a = owned_item_id(33068179, 100000001);
    assert_eq!(a, owned_item_id(33068179, 100000001), "stable across calls");
    assert_ne!(
        a,
        owned_item_id(33068179, 100000002),
        "distinct per wire id"
    );
    assert_ne!(
        a,
        owned_item_id(90909090, 100000001),
        "distinct per account"
    );
}

#[test]
fn plan_apply_builds_generic_request_mappings_and_squad() {
    let items = vec![
        player(100000001, 20801, 20801, 94),
        player(100000002, VER5_176580, 176580, 92),
    ];
    let (report, raw) = report_and_raw(&items, SQUAD_F433, 100000500);
    let plan = plan_apply(&report, &raw, "fp-test").unwrap();

    assert_eq!(plan.request.owned.len(), 2);
    assert_eq!(plan.mappings.len(), 2);
    assert_eq!(plan.watermark, 100000500);
    assert_eq!(plan.supported_instances, 2);
    assert_eq!(plan.deferred_instances, 0);
    assert_eq!(plan.source_fingerprint, "fp-test");
    // card ids are fifa17_<resourceId>, base and versioned distinct.
    let mut cards: Vec<&str> = plan
        .request
        .owned
        .iter()
        .map(|o| o.card_id.as_str())
        .collect();
    cards.sort_unstable();
    assert_eq!(
        cards,
        vec!["fifa17_20801", &format!("fifa17_{VER5_176580}")[..]]
    );
    // mapping core_id == the owned_item_id for that wire (both stores agree).
    for m in &plan.mappings {
        assert_eq!(m.core_id, owned_item_id(33068179, m.wire_id));
    }
    // squad + opaque extension.
    let sq = plan.request.squad.as_ref().expect("squad present");
    assert_eq!(sq.formation, "f433");
    assert_eq!(sq.slots.len(), 2);
    assert_eq!(sq.extension.namespace, "fifa17.squad");
    assert_eq!(sq.extension.schema_version, 1);
    assert!(
        sq.extension.payload.contains("[0,0,0]"),
        "custom carried verbatim"
    );
    // captain flag follows wire 100000001, keyed by its OwnedItemId.
    let cap_owned = owned_item_id(33068179, 100000001);
    assert!(sq
        .slots
        .iter()
        .any(|s| s.owned_item_id == cap_owned && s.is_captain));
}

#[test]
fn plan_apply_refuses_when_blockers_present() {
    // unapproved same-resourceId conflict -> blocker.
    let items = vec![
        player(100000003, 20801, 20801, 94),
        player(100000004, 20801, 20801, 90),
    ];
    let (report, raw) = report_and_raw(&items, "[]", 100000500);
    assert!(plan_apply(&report, &raw, "fp").is_err());
}

#[test]
fn gate_staging_requires_explicit_optin_for_deferred() {
    // one supported + one NoName (unresolvable asset) deferred instance.
    let items = vec![
        player(100000001, 20801, 20801, 94),
        format!(
            r#"{{"id":100000009,"resourceId":999999,"assetId":999999,"itemType":"player","rating":80,
            "preferredPosition":"ST","nation":38,"teamid":243,"leagueId":53,"attributeList":{}}}"#,
            attrs()
        ),
    ];
    let (report, raw) = report_and_raw(&items, "[]", 100000500);
    let plan = plan_apply(&report, &raw, "fp").unwrap();
    assert_eq!(plan.deferred_instances, 1);
    assert!(gate_staging(&plan, false).is_err(), "deferred needs opt-in");
    assert!(gate_staging(&plan, true).unwrap(), "staging with opt-in");
}

#[test]
fn gate_staging_production_complete_needs_no_flag() {
    let items = vec![player(100000001, 20801, 20801, 94)];
    let (report, raw) = report_and_raw(&items, "[]", 100000500);
    let plan = plan_apply(&report, &raw, "fp").unwrap();
    assert_eq!(plan.deferred_instances, 0);
    assert!(!gate_staging(&plan, false).unwrap());
}

#[test]
fn local_preflight_rejects_owned_card_absent_from_content() {
    let items = vec![player(100000001, 20801, 20801, 94)];
    let (report, raw) = report_and_raw(&items, "[]", 100000500);
    let plan = plan_apply(&report, &raw, "fp").unwrap();
    let mut present = BTreeSet::new();
    present.insert("fifa17_20801".to_string());
    assert!(local_core_preflight(&plan, &present).is_ok());
    assert!(local_core_preflight(&plan, &BTreeSet::new()).is_err());
}

#[test]
fn identity_seed_dry_postvalidate_and_idempotent_rerun() {
    let items = vec![
        player(100000001, 20801, 20801, 94),
        player(100000002, VER5_176580, 176580, 92),
    ];
    let (report, raw) = report_and_raw(&items, SQUAD_F433, 100004617);
    let plan = plan_apply(&report, &raw, "fp").unwrap();

    let dir = tempfile::tempdir().unwrap();
    let store = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap();
    identity_dry_preflight(&store, &plan).unwrap();
    seed_identity(&store, &plan).unwrap();

    // every preserved wire id resolves exactly; watermark set.
    for m in &plan.mappings {
        assert_eq!(
            store
                .external_for("fifa17", "owned-item", &m.core_id)
                .unwrap(),
            Some(m.wire_id)
        );
    }
    assert_eq!(store.watermark_for("fifa17", "owned-item"), Some(100004617));
    post_validate_identity(&store, &plan).unwrap();

    // re-seeding the SAME plan is an idempotent no-op (crash recovery).
    seed_identity(&store, &plan).unwrap();
    post_validate_identity(&store, &plan).unwrap();
}

#[test]
fn identity_dry_preflight_detects_conflicting_existing_mapping() {
    let items = vec![player(100000001, 20801, 20801, 94)];
    let (report, raw) = report_and_raw(&items, "[]", 100000500);
    let plan = plan_apply(&report, &raw, "fp").unwrap();

    let dir = tempfile::tempdir().unwrap();
    let store = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap();
    // wire 100000001 already owned by a DIFFERENT core id.
    store
        .insert_existing_mapping("fifa17", "owned-item", "someone-else", 100000001)
        .unwrap();
    assert!(identity_dry_preflight(&store, &plan).is_err());
}

#[test]
fn content_card_ids_reads_emitted_pack() {
    let items = vec![player(100000001, 20801, 20801, 94)];
    let rep = analyze(
        &profile(&items, "[]", 100000500),
        &roster(),
        &entities(),
        &none(),
    );
    let dir = tempfile::tempdir().unwrap();
    let sum = emit_content(&rep, dir.path(), "fp").unwrap();
    let ids = content_card_ids(&sum.content_pack).unwrap();
    assert!(ids.contains("fifa17_20801"));
}

/// The full apply spawns the Core binary; that end-to-end path is exercised by
/// the staged runtime gate, not here. This asserts the orchestration refuses
/// cleanly (no panic, no partial identity seed) when the Core binary is absent
/// AFTER the two local gates pass — proving ordering: gates first, spawn last.
#[test]
fn apply_fails_gracefully_when_core_binary_missing() {
    use crate::apply::ApplyPaths;
    let items = vec![player(100000001, 20801, 20801, 94)];
    let (report, raw) = report_and_raw(&items, "[]", 100000500);
    let plan = plan_apply(&report, &raw, "fp").unwrap();
    let dir = tempfile::tempdir().unwrap();
    // emit a real content pack so local preflight passes.
    let rep2 = analyze(
        &profile(&items, "[]", 100000500),
        &roster(),
        &entities(),
        &none(),
    );
    let sum = emit_content(&rep2, dir.path(), "fp").unwrap();
    let paths = ApplyPaths {
        core_bin: dir.path().join("no-such-core-binary"),
        core_db_url: "sqlite::memory:".to_string(),
        content_pack: sum.content_pack,
        data_dir: "data".to_string(),
        identity_store: dir.path().join("ids.json"),
        request_out: dir.path().join("req.json"),
        completion_out: dir.path().join("done.json"),
    };
    let err = apply_import(&plan, &paths, false).unwrap_err();
    assert!(format!("{err:#}").contains("spawn core import"), "{err:#}");
}
