//! `--apply`: the FIFA17-specific orchestration that installs a real profile
//! across the TWO durable stores — the `openfut-identity` external-id store and
//! OpenFUT Core's SQLite DB — recoverably and idempotently.
//!
//! Boundary: everything FIFA17-specific stays here. This module chooses every
//! `CardDefinitionId` (`fifa17_<resourceId>`, from the report) and every
//! deterministic opaque Core `OwnedItemId`, preserves each owned instance's
//! existing Python wire id, builds the canonical squad + `Fifa17SquadExtensionV1`
//! with the SAME adapter code the retail-validated live squad path uses, then
//! hands Core a GENERIC [`GenericImportRequest`]. Core never sees a resourceId,
//! a wire id, or the extension's meaning.
//!
//! Recoverable two-store protocol (see [`apply`]):
//!   validate → local Core-preflight mirror → identity dry-preflight →
//!   idempotently install existing wire-id mappings + watermark →
//!   ONE generic Core import transaction → cross-store post-validation →
//!   completion record.
//! If Core fails after identity seeding, rerunning the same manifest re-installs
//! identical identity mappings (idempotent) and Core converges (same
//! `source_fingerprint` → `already_imported`). No cleanup, no reminting.

use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{bail, Context, Result};
use serde::Serialize;
use uuid::Uuid;

use openfut_adapter_fifa17::fut::catalog::Fifa17WireItemIdPolicy;
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, SquadWireResolver};
use openfut_adapter_fifa17::fut::squad_ext::{
    build_squad_write, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
};
use openfut_identity::{ExternalIdentityStore, JsonIdentityStore};

use crate::Report;

/// Private, project-specific UUIDv5 namespace for deriving Core OwnedItemIds from
/// a source account + source owned-item identity. Fixed forever so a re-import of
/// the same account yields the SAME opaque ids (idempotent across both stores).
/// Core never parses why these ids are stable — they are opaque primary keys.
const OWNED_ITEM_NAMESPACE: Uuid = Uuid::from_bytes([
    0x0f, 0x17, 0x0f, 0x17, 0x9a, 0x11, 0x5e, 0xd0, 0xb0, 0x0f, 0x0e, 0x11, 0x0c, 0x0a, 0x11, 0x17,
]);
/// Deterministic opaque Core OwnedItemId for one source owned instance. Stable
/// across runs and identical in BOTH stores (identity mapping core_id AND Core
/// `owned_cards.id`), so the running host's wire→owned reverse lookup resolves
/// exactly what Core stored.
pub fn owned_item_id(persona_id: i64, source_wire_id: i64) -> String {
    let name = format!("fifa17:{persona_id}:{source_wire_id}");
    Uuid::new_v5(&OWNED_ITEM_NAMESPACE, name.as_bytes()).to_string()
}

// ----------------------------------------------------- generic Core request

#[derive(Debug, Serialize)]
pub struct GenericProfile {
    pub username: String,
    pub game_id: String,
}

#[derive(Debug, Serialize)]
pub struct GenericClub {
    pub name: String,
    pub coins: i64,
}

#[derive(Debug, Serialize)]
pub struct GenericOwned {
    pub owned_item_id: String,
    pub card_id: String,
}

#[derive(Debug, Serialize)]
pub struct GenericSlot {
    pub owned_item_id: String,
    pub position_index: i64,
    pub is_captain: bool,
    pub is_on_bench: bool,
}

#[derive(Debug, Serialize)]
pub struct GenericExtension {
    pub namespace: String,
    pub schema_version: i64,
    pub payload: String,
}

#[derive(Debug, Serialize)]
pub struct GenericSquad {
    pub formation: String,
    pub name: String,
    pub slots: Vec<GenericSlot>,
    pub extension: GenericExtension,
}

/// The game-agnostic request Core consumes. Field names match
/// `openfut_core::services::import::ProfileImportRequest` exactly.
#[derive(Debug, Serialize)]
pub struct GenericImportRequest {
    pub source_fingerprint: String,
    pub profile: GenericProfile,
    pub club: GenericClub,
    pub owned: Vec<GenericOwned>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub squad: Option<GenericSquad>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub entitlements: Vec<GenericEntitlement>,
}

/// One unconsumed entitlement to seed into Core (a source `unopenedPackIds`
/// entry). `definition_id` is the pack id as text; Core stores it verbatim.
#[derive(Debug, Serialize)]
pub struct GenericEntitlement {
    pub definition_id: String,
}

/// One preserved (opaque OwnedItemId ↔ source wire id) mapping to install into
/// the identity store under `(fifa17, owned-item)`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentityMapping {
    pub core_id: String,
    pub wire_id: i64,
}

/// The complete, side-effect-free plan for an apply: the generic Core request,
/// the identity mappings to install, and the watermark to set.
#[derive(Debug)]
pub struct ApplyPlan {
    pub request: GenericImportRequest,
    pub mappings: Vec<IdentityMapping>,
    pub watermark: i64,
    pub supported_instances: usize,
    pub deferred_instances: usize,
    pub source_fingerprint: String,
}

struct MapResolver<'a>(&'a HashMap<i64, String>);
impl SquadWireResolver for MapResolver<'_> {
    fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
        self.0.get(&wire).cloned()
    }
}

/// Build the full apply plan from an already-analysed [`Report`] + the raw
/// profile JSON (needed verbatim for the squad wire: `custom`, `kicktakers`,
/// `kitNumber`, manager, squadType). Pure: no store or Core writes.
///
/// Refuses if the report has blockers — never plans an unsafe or lossy import.
pub fn plan_apply(
    report: &Report,
    raw_profile: &serde_json::Value,
    snapshot_fingerprint: &str,
) -> Result<ApplyPlan> {
    if report.has_blockers() {
        bail!(
            "refusing to plan apply while blockers are present: {}",
            report.blockers().join("; ")
        );
    }
    let persona = report.persona_id;

    // Owned cards + identity mappings: one per SUPPORTED owned instance. Each
    // instance keeps its existing Python wire id; its Core OwnedItemId is derived
    // deterministically from (persona, wire).
    let mut owned = Vec::with_capacity(report.identity.import_wire_ids.len());
    let mut mappings = Vec::with_capacity(report.identity.import_wire_ids.len());
    let mut wire_to_owned: HashMap<i64, String> = HashMap::new();
    for def in &report.definitions.supported {
        for &wire in &def.wire_ids {
            let core_id = owned_item_id(persona, wire);
            wire_to_owned.insert(wire, core_id.clone());
            owned.push(GenericOwned {
                owned_item_id: core_id.clone(),
                card_id: def.card_id.clone(),
            });
            mappings.push(IdentityMapping {
                core_id,
                wire_id: wire,
            });
        }
    }
    // Non-player (consumable/staff) owned instances mint via the IDENTICAL
    // generic path: deterministic OwnedItemId per (persona, wire), an identity
    // mapping, and a GenericOwned with card_id = fifa17_<resourceId>.
    for def in &report.non_player.supported {
        for &wire in &def.wire_ids {
            let core_id = owned_item_id(persona, wire);
            wire_to_owned.insert(wire, core_id.clone());
            owned.push(GenericOwned {
                owned_item_id: core_id.clone(),
                card_id: def.card_id.clone(),
            });
            mappings.push(IdentityMapping {
                core_id,
                wire_id: wire,
            });
        }
    }

    // Canonical squad + opaque extension, built by the SAME adapter code the live
    // squad-write path uses, over the raw source squad. The resolver maps every
    // occupied wire id to its deterministic OwnedItemId.
    let squad = if report.squad.present {
        let raw_squad = raw_profile
            .get("squads")
            .and_then(|s| s.get(0))
            .context("report says a squad is present but profile has no squads[0]")?;
        let body = serde_json::to_vec(raw_squad).context("re-serialize source squad")?;
        let mut put =
            parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
        // A HISTORICAL profile may reference a manager whose owned instance is
        // not imported (unsupported/deferred, or a dangling id with no owned
        // item at all). Drop such a manager ref here rather than failing the
        // whole import — the manager assignment is only imported when its owned
        // instance is. (A LIVE squad PUT still refuses an unresolved manager,
        // because the client is actively assigning one it must own.)
        put.manager.retain(|m| wire_to_owned.contains_key(&m.id));
        let build = build_squad_write(&put, &MapResolver(&wire_to_owned))
            .map_err(|e| anyhow::anyhow!("build squad write: {e}"))?;
        let formation = build
            .canonical
            .formation
            .clone()
            .context("source squad has no formation token")?;
        let slots = build
            .canonical
            .slots
            .iter()
            .map(|s| GenericSlot {
                owned_item_id: s.owned_card_id.clone(),
                position_index: s.index,
                is_captain: s.is_captain,
                is_on_bench: s.is_on_bench,
            })
            .collect();
        Some(GenericSquad {
            formation,
            name: build
                .canonical
                .name
                .clone()
                .unwrap_or_else(|| report.club_name.clone()),
            slots,
            extension: GenericExtension {
                namespace: EXT_NAMESPACE.to_string(),
                schema_version: EXT_SCHEMA_VERSION,
                payload: build.extension.to_payload(),
            },
        })
    } else {
        None
    };

    let request = GenericImportRequest {
        source_fingerprint: snapshot_fingerprint.to_string(),
        profile: GenericProfile {
            username: report.persona_name.clone(),
            game_id: report.game.clone(),
        },
        club: GenericClub {
            name: report.club_name.clone(),
            coins: report.coins,
        },
        owned,
        squad,
        entitlements: report
            .unopened_pack_ids
            .iter()
            .map(|id| GenericEntitlement {
                definition_id: id.to_string(),
            })
            .collect(),
    };

    Ok(ApplyPlan {
        request,
        mappings,
        watermark: report.identity.source_watermark,
        supported_instances: report.identity.import_wire_ids.len()
            + report
                .non_player
                .supported
                .iter()
                .map(|d| d.wire_ids.len())
                .sum::<usize>(),
        deferred_instances: report.deferred_instances() + report.non_player.deferred_instances(),
        source_fingerprint: snapshot_fingerprint.to_string(),
    })
}

// -------------------------------------------------------------- side effects

/// Where the apply reads/writes the two stores and Core.
pub struct ApplyPaths {
    /// The `openfut-core` binary to invoke for the generic import transaction.
    pub core_bin: PathBuf,
    /// `DATABASE_URL` passed to that Core invocation (the target profile DB).
    pub core_db_url: String,
    /// The emitted production content pack (`OPENFUT_CONTENT_PACKS`).
    pub content_pack: PathBuf,
    /// `DATA_DIR` for the Core invocation (base catalog directory).
    pub data_dir: String,
    /// The identity store JSON file.
    pub identity_store: PathBuf,
    /// Where to write the generic request JSON handed to Core.
    pub request_out: PathBuf,
    /// Where to write the completion record.
    pub completion_out: PathBuf,
}

#[derive(Debug, Serialize)]
pub struct ApplyOutcome {
    pub core_outcome: String,
    pub mappings_installed: usize,
    pub watermark: i64,
    pub supported_instances: usize,
    pub deferred_instances: usize,
    pub staging: bool,
    pub source_fingerprint: String,
}

/// The staging/production gate. Deferred players are absent from an import; that
/// is acceptable ONLY for a staged run, and ONLY with the explicit opt-in — never
/// a silent default. Production requires zero deferred instances.
pub fn gate_staging(plan: &ApplyPlan, allow_deferred_staging: bool) -> Result<bool> {
    if plan.deferred_instances == 0 {
        return Ok(false); // production-complete
    }
    if !allow_deferred_staging {
        bail!(
            "{} player instance(s) are DEFERRED (unsupported/conflicted) and would be ABSENT from \
             this import. Production import requires zero deferred instances. To import anyway for \
             a STAGED test, pass --allow-deferred-players-for-staging (this is never the default).",
            plan.deferred_instances
        );
    }
    eprintln!(
        "\n================= STAGING IMPORT (INCOMPLETE) =================\n\
         {} player instance(s) are DEFERRED and WILL BE ABSENT from this import.\n\
         This profile is NOT production-complete. Resolve the deferred definitions\n\
         before a production cutover.\n\
         ==============================================================\n",
        plan.deferred_instances
    );
    Ok(true)
}

/// Local mirror of Core's pre-transaction preflight, run BEFORE touching either
/// store so a doomed import never seeds identity. `content_card_ids` is the set
/// of CardDefinitionIds in the emitted production pack.
pub fn local_core_preflight(plan: &ApplyPlan, content_card_ids: &BTreeSet<String>) -> Result<()> {
    let mut owned_ids: BTreeSet<&str> = BTreeSet::new();
    for o in &plan.request.owned {
        if !content_card_ids.contains(&o.card_id) {
            bail!(
                "local preflight: owned card references CardDefinitionId {} absent from the \
                 production content pack",
                o.card_id
            );
        }
        if !owned_ids.insert(o.owned_item_id.as_str()) {
            bail!("local preflight: duplicate OwnedItemId {}", o.owned_item_id);
        }
    }
    if let Some(sq) = &plan.request.squad {
        for slot in &sq.slots {
            if !owned_ids.contains(slot.owned_item_id.as_str()) {
                bail!(
                    "local preflight: squad slot OwnedItemId {} not in imported ownership set",
                    slot.owned_item_id
                );
            }
        }
    }
    Ok(())
}

/// Dry-check that installing the identity mappings would not conflict with any
/// mapping already present (a re-run's identical mappings are fine). No writes.
pub fn identity_dry_preflight(store: &JsonIdentityStore, plan: &ApplyPlan) -> Result<()> {
    let (g, k) = (
        Fifa17WireItemIdPolicy::GAME,
        Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
    );
    let mut conflicts = Vec::new();
    for m in &plan.mappings {
        if let Some(existing) = store.external_for(g, k, &m.core_id)? {
            if existing != m.wire_id {
                conflicts.push(format!(
                    "core {} already maps to wire {existing} (want {})",
                    m.core_id, m.wire_id
                ));
            }
        }
        if let Some(existing) = store.core_for(g, k, m.wire_id)? {
            if existing != m.core_id {
                conflicts.push(format!(
                    "wire {} already maps to core {existing} (want {})",
                    m.wire_id, m.core_id
                ));
            }
        }
    }
    if !conflicts.is_empty() {
        bail!(
            "identity dry-preflight found {} conflict(s): {}",
            conflicts.len(),
            conflicts.join("; ")
        );
    }
    Ok(())
}

/// Idempotently install every preserved wire-id mapping + the allocation
/// watermark. Re-running re-inserts identical mappings (no-op) and re-sets the
/// same watermark — safe after a mid-apply crash.
pub fn seed_identity(store: &JsonIdentityStore, plan: &ApplyPlan) -> Result<()> {
    let (g, k) = (
        Fifa17WireItemIdPolicy::GAME,
        Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
    );
    for m in &plan.mappings {
        store
            .insert_existing_mapping(g, k, &m.core_id, m.wire_id)
            .with_context(|| format!("install mapping {} -> {}", m.core_id, m.wire_id))?;
    }
    store
        .set_watermark(g, k, plan.watermark)
        .context("set allocation watermark")?;
    Ok(())
}

/// After a successful Core import, re-open the identity store and assert every
/// preserved wire id resolves exactly, and the watermark is set. Core's own
/// return (`imported`/`already_imported`) is the Core-side proof.
pub fn post_validate_identity(store: &JsonIdentityStore, plan: &ApplyPlan) -> Result<()> {
    let (g, k) = (
        Fifa17WireItemIdPolicy::GAME,
        Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
    );
    for m in &plan.mappings {
        let got = store.external_for(g, k, &m.core_id)?;
        if got != Some(m.wire_id) {
            bail!(
                "post-validate: core {} resolves to {:?}, expected wire {}",
                m.core_id,
                got,
                m.wire_id
            );
        }
    }
    match store.watermark_for(g, k) {
        Some(w) if w == plan.watermark => Ok(()),
        other => bail!(
            "post-validate: watermark is {:?}, expected {}",
            other,
            plan.watermark
        ),
    }
}

/// Load the CardDefinitionIds from an emitted production content pack.
pub fn content_card_ids(pack: &Path) -> Result<BTreeSet<String>> {
    let raw =
        std::fs::read(pack).with_context(|| format!("read content pack {}", pack.display()))?;
    let defs: Vec<serde_json::Value> =
        serde_json::from_slice(&raw).context("parse content pack as CardDefinition[]")?;
    Ok(defs
        .iter()
        .filter_map(|d| d.get("id").and_then(|v| v.as_str()).map(String::from))
        .collect())
}

/// Full recoverable apply across both stores. See module docs for the protocol.
pub fn apply(
    plan: &ApplyPlan,
    paths: &ApplyPaths,
    allow_deferred_staging: bool,
) -> Result<ApplyOutcome> {
    // 1. staging/production gate (explicit, never silent).
    let staging = gate_staging(plan, allow_deferred_staging)?;

    // 2. local Core-preflight mirror BEFORE any store write.
    let card_ids = content_card_ids(&paths.content_pack)?;
    local_core_preflight(plan, &card_ids)?;

    // 3. identity dry-preflight, then idempotent seed + watermark.
    let store = JsonIdentityStore::open(&paths.identity_store)
        .with_context(|| format!("open identity store {}", paths.identity_store.display()))?;
    identity_dry_preflight(&store, plan)?;
    seed_identity(&store, plan)?;

    // 4. one generic Core import transaction (Core owns atomicity).
    let req_json = serde_json::to_vec_pretty(&plan.request)?;
    std::fs::write(&paths.request_out, &req_json)
        .with_context(|| format!("write request {}", paths.request_out.display()))?;
    let out = Command::new(&paths.core_bin)
        .arg("import")
        .arg(&paths.request_out)
        .env("DATABASE_URL", &paths.core_db_url)
        .env("DATA_DIR", &paths.data_dir)
        .env("OPENFUT_CONTENT_PACKS", paths.content_pack.as_os_str())
        .output()
        .with_context(|| format!("spawn core import: {}", paths.core_bin.display()))?;
    if !out.status.success() {
        bail!(
            "core import failed ({}): {}\n(identity already seeded idempotently; re-run the same \
             manifest to converge — no cleanup needed)",
            out.status,
            String::from_utf8_lossy(&out.stderr)
        );
    }
    let stdout = String::from_utf8_lossy(&out.stdout);
    let core_result: serde_json::Value =
        serde_json::from_str(stdout.trim()).context("parse core import outcome JSON")?;
    let core_outcome = core_result
        .get("outcome")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string();

    // 5. cross-store post-validation.
    post_validate_identity(&store, plan)?;

    // 6. completion record.
    let outcome = ApplyOutcome {
        core_outcome,
        mappings_installed: plan.mappings.len(),
        watermark: plan.watermark,
        supported_instances: plan.supported_instances,
        deferred_instances: plan.deferred_instances,
        staging,
        source_fingerprint: plan.source_fingerprint.clone(),
    };
    let rec = serde_json::to_vec_pretty(&outcome)?;
    std::fs::write(&paths.completion_out, &rec)
        .with_context(|| format!("write completion {}", paths.completion_out.display()))?;
    Ok(outcome)
}
