//! Card asset loading and entity lifecycle: the sync systems that
//! spawn, update, and position card entities from `GameStateResource`.

use super::*;

use std::collections::{HashMap, HashSet};

use bevy::color::Color;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};

use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
use crate::events::StateChangedEvent;
use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource};
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};

/// Rebuild the [`CardEntityIndex`] from the live `CardEntity` set.
///
/// Runs in `PostUpdate` so that all spawn/despawn `Commands` issued by
/// [`sync_cards_on_change`] and [`snap_cards_on_window_resize`] in `Update`
/// have been flushed at the `Update -> PostUpdate` apply-deferred boundary.
/// Rebuilding from scratch (rather than incrementally patching at every
/// spawn/despawn site — waste cards churn on every draw) keeps a single writer
/// and makes a stale entry structurally impossible.
///
/// Gated to changed frames only: `Changed<CardEntity>` fires the frame a card
/// is spawned, `RemovedComponents<CardEntity>` the frame one is despawned. The
/// `card` field is write-once (never mutated in place), so card-reposition
/// frames don't trip `Changed` and correctly skip the O(52) rebuild.
pub(super) fn rebuild_card_entity_index(
    mut index: ResMut<CardEntityIndex>,
    cards: Query<(Entity, &CardEntity)>,
    changed: Query<(), Changed<CardEntity>>,
    removed: RemovedComponents<CardEntity>,
) {
    if changed.is_empty() && removed.is_empty() {
        return;
    }
    let map = &mut index.0;
    map.clear();
    for (entity, ce) in &cards {
        map.insert(ce.card.clone(), entity);
    }
}

/// Returns the relative asset path for a card face PNG.
///
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
/// this function so the filename formula is tested in isolation from the
/// asset-loading machinery.
///
/// Note: this function verifies only the **code-side mapping**. If the PNG
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
/// diamond watermark baked in), that is an **asset content bug** and must be
/// fixed by replacing the file — no code change can correct it.
pub(super) fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
    const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
    const RANK_STRS: [&str; 13] = [
        "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
    ];
    let suit_idx = match suit {
        Suit::Clubs => 0,
        Suit::Diamonds => 1,
        Suit::Hearts => 2,
        Suit::Spades => 3,
    };
    let rank_idx = match rank {
        Rank::Ace => 0,
        Rank::Two => 1,
        Rank::Three => 2,
        Rank::Four => 3,
        Rank::Five => 4,
        Rank::Six => 5,
        Rank::Seven => 6,
        Rank::Eight => 7,
        Rank::Nine => 8,
        Rank::Ten => 9,
        Rank::Jack => 10,
        Rank::Queen => 11,
        Rank::King => 12,
    };
    format!(
        "cards/faces/classic/{}{}.png",
        RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
    )
}

/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
/// [`CardImageSet`].
///
/// Faces: `assets/cards/faces/{RANK}{SUIT}.png`  (e.g. `AC.png`, `10H.png`)
/// Backs: `assets/cards/backs/back_{0..4}.png`
///
/// Under `MinimalPlugins` (tests) `AssetServer` is absent, so the system
/// returns without inserting `CardImageSet` and the plugin falls back to
/// solid-colour sprites.
pub(super) fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
    let Some(asset_server) = asset_server else {
        return;
    };

    // faces[suit_index(s)][rank_index(r)] — see the canonical helpers in
    // card_plugin::mod; building from SUITS/RANKS order matches them.
    let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
        std::array::from_fn(|ri| {
            asset_server.load(card_face_asset_path(Rank::RANKS[ri], Suit::SUITS[si]))
        })
    });
    let backs =
        std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png")));
    commands.insert_resource(CardImageSet {
        faces,
        backs,
        // Populated by the theme plugin once a `CardTheme` finishes loading.
        // Until then the legacy back fallback (`backs[selected_card_back]`)
        // is used.
        theme_back: None,
    });
}

/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
/// available and falling back to a solid-colour sprite in tests.
pub(super) fn card_sprite(
    card: &Card,
    face_up: bool,
    card_size: Vec2,
    back_colour: Color,
    card_images: Option<&CardImageSet>,
    selected_back: usize,
) -> Sprite {
    if let Some(set) = card_images {
        let image = if face_up {
            let suit_idx = suit_index(card.suit());
            let rank_idx = rank_index(card.rank());
            set.faces[suit_idx][rank_idx].clone()
        } else if let Some(theme_back) = &set.theme_back {
            // Active theme provides its own back — always wins over the
            // legacy `selected_card_back` picker, so a theme switch swaps
            // faces *and* the back. The picker is treated as informational
            // only while a theme back is active (see settings_plugin).
            theme_back.clone()
        } else {
            let idx = selected_back.min(set.backs.len() - 1);
            set.backs[idx].clone()
        };
        Sprite {
            image,
            color: Color::WHITE,
            custom_size: Some(card_size),
            ..default()
        }
    } else {
        // Terminal aesthetic: face background is uniformly CARD_FACE_COLOUR
        // regardless of colour-blind mode (CBM differentiation now lives in
        // the suit glyph colour, applied by `text_colour`, not the face
        // background). Pre-Terminal this branch dispatched through a
        // separate `face_colour(card, color_blind)` helper.
        let body_colour = if face_up {
            CARD_FACE_COLOUR
        } else {
            back_colour
        };
        Sprite {
            color: body_colour,
            custom_size: Some(card_size),
            ..default()
        }
    }
}

/// When card-back selection changes in Settings, re-render all cards so the
/// new back colour is applied immediately (without waiting for a state change).
pub(super) fn resync_cards_on_settings_change(
    mut setting_events: MessageReader<SettingsChangedEvent>,
    mut state_events: MessageWriter<StateChangedEvent>,
) {
    if setting_events.read().next().is_some() {
        state_events.write(StateChangedEvent);
    }
}

/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
/// have already completed.
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_cards_startup(
    commands: Commands,
    game: Res<GameStateResource>,
    layout: Option<Res<LayoutResource>>,
    slide_dur: Option<Res<EffectiveSlideDuration>>,
    settings: Option<Res<SettingsResource>>,
    entities: Query<CardSyncData>,
    card_images: Option<Res<CardImageSet>>,
    font_res: Option<Res<FontResource>>,
) {
    if let Some(layout) = layout {
        let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
        let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
        let back_colour = card_back_colour(selected_back);
        let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
        let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
        let font_handle = font_res.as_ref().map(|r| &r.0);
        sync_cards(
            commands,
            &game.0,
            &layout.0,
            slide_secs,
            back_colour,
            color_blind,
            high_contrast,
            &entities,
            card_images.as_deref(),
            selected_back,
            font_handle,
        );
    }
}

#[allow(clippy::too_many_arguments)]
pub(super) fn sync_cards_on_change(
    mut events: MessageReader<StateChangedEvent>,
    commands: Commands,
    game: Res<GameStateResource>,
    layout: Option<Res<LayoutResource>>,
    slide_dur: Option<Res<EffectiveSlideDuration>>,
    settings: Option<Res<SettingsResource>>,
    entities: Query<CardSyncData>,
    card_images: Option<Res<CardImageSet>>,
    font_res: Option<Res<FontResource>>,
) {
    if events.read().next().is_none() {
        return;
    }
    if let Some(layout) = layout {
        let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
        let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
        let back_colour = card_back_colour(selected_back);
        let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
        let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
        let font_handle = font_res.as_ref().map(|r| &r.0);
        sync_cards(
            commands,
            &game.0,
            &layout.0,
            slide_secs,
            back_colour,
            color_blind,
            high_contrast,
            &entities,
            card_images.as_deref(),
            selected_back,
            font_handle,
        );
    }
}

#[allow(clippy::too_many_arguments)]
pub(super) fn sync_cards(
    mut commands: Commands,
    game: &GameState,
    layout: &Layout,
    slide_secs: f32,
    back_colour: Color,
    color_blind: bool,
    high_contrast: bool,
    entities: &Query<CardSyncData>,
    card_images: Option<&CardImageSet>,
    selected_back: usize,
    font_handle: Option<&Handle<Font>>,
) {
    let positions = card_positions(game, layout);

    // The waste buffer card exists only to keep its entity alive while the new
    // top card's slide animation plays — it must never be visible to the player.
    // Without this, the buffer sits at waste_base uncovered during the animation
    // and its rank/suit peek behind the incoming card.
    let waste_buffer_id: Option<Card> = {
        let visible = match game.draw_mode() {
            DrawStockConfig::DrawOne => 1_usize,
            DrawStockConfig::DrawThree => 3_usize,
        };
        let waste_cards = game.waste_cards();
        (waste_cards.len() > visible)
            .then_some(waste_cards)
            .and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
            .map(|(c, _face_up)| c)
    };

    // Map Card -> (Entity, current_translation, anim_end) for in-place
    // updates.  `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
    // is currently driving the card (e.g. a drag-rejection return tween).
    //
    // In the position loop below we compare `anim_end` against the new game-
    // state target position to decide whether to honour or cancel the tween:
    //   • end ≈ target  → animation is still heading to the right place; let
    //                      it finish (skip the snap/slide path).
    //   • end ≠ target  → the game state has changed (e.g. a new game started
    //                      while the win-cascade was mid-flight); cancel the
    //                      stale `CardAnimation` and apply the new position.
    let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
        HashMap::new();
    for (entity, marker, transform, anim, children_key) in entities.iter() {
        existing.insert(
            marker.card.clone(),
            (
                entity,
                transform.translation,
                anim.map(|a| a.end),
                children_key.copied(),
            ),
        );
    }

    let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();

    // Despawn any entity whose card is no longer tracked.
    for (card, (entity, _, _, _)) in &existing {
        if !live_ids.contains(card) {
            commands.entity(*entity).despawn();
        }
    }

    // For each card in the current state: spawn or update its entity, then
    // apply visibility. The waste buffer card is hidden so it cannot peek
    // behind the incoming top card during the draw slide animation.
    for ((card, face_up), position, z) in positions {
        let entity = match existing.get(&card) {
            Some(&(entity, cur, anim_end, children_key)) => {
                // If a CardAnimation is in flight, check whether its destination
                // still matches the game-state target.  If the game moved the card
                // elsewhere (e.g. new game started during a win-cascade scatter),
                // cancel the stale tween so the card snaps/slides to its new home.
                let has_anim = match anim_end {
                    Some(end_xy) if (end_xy - position).length() > 2.0 => {
                        commands.entity(entity).remove::<CardAnimation>();
                        false
                    }
                    Some(_) => true,
                    None => false,
                };
                update_card_entity(
                    &mut commands,
                    entity,
                    &card,
                    face_up,
                    position,
                    z,
                    layout,
                    slide_secs,
                    back_colour,
                    color_blind,
                    high_contrast,
                    cur,
                    has_anim,
                    children_key,
                    card_images,
                    selected_back,
                    font_handle,
                );
                entity
            }
            None => spawn_card_entity(
                &mut commands,
                &card,
                face_up,
                position,
                z,
                layout,
                back_colour,
                color_blind,
                high_contrast,
                card_images,
                selected_back,
                font_handle,
            ),
        };
        let visibility = if waste_buffer_id.as_ref() == Some(&card) {
            Visibility::Hidden
        } else {
            Visibility::Inherited
        };
        commands.entity(entity).insert(visibility);
    }
}

/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game.
pub(super) fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> {
    let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52);
    let piles = [
        (KlondikePile::Stock, true),
        (KlondikePile::Stock, false),
        (KlondikePile::Foundation(Foundation::Foundation1), false),
        (KlondikePile::Foundation(Foundation::Foundation2), false),
        (KlondikePile::Foundation(Foundation::Foundation3), false),
        (KlondikePile::Foundation(Foundation::Foundation4), false),
        (KlondikePile::Tableau(Tableau::Tableau1), false),
        (KlondikePile::Tableau(Tableau::Tableau2), false),
        (KlondikePile::Tableau(Tableau::Tableau3), false),
        (KlondikePile::Tableau(Tableau::Tableau4), false),
        (KlondikePile::Tableau(Tableau::Tableau5), false),
        (KlondikePile::Tableau(Tableau::Tableau6), false),
        (KlondikePile::Tableau(Tableau::Tableau7), false),
    ];

    // Draw-Three waste fan step, proportional to the column spacing so it scales
    // with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
    // `waste_fan_step` so the two never drift (a drift puts the top fanned card's
    // click target on the card beneath it).
    let waste_fan_step = waste_fan_step(layout);

    for (pile_type, is_stock_area) in piles {
        let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
            continue;
        };
        if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
            base.x -= tableau_col_step(layout);
        }
        let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
        let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
        let cards = if matches!(pile_type, KlondikePile::Stock) {
            if is_stock_area {
                game.stock_cards()
            } else {
                game.waste_cards()
            }
        } else {
            game.pile(pile_type)
        };

        // Tableau uses a two-speed fan: face-down cards are packed tighter
        // than face-up cards so the visible (playable) portion stands out.
        // Non-tableau piles stack with a negligible offset.
        //
        // Waste pile: only the top N cards are rendered to prevent bleed-through
        // while new cards animate in from the stock. Draw-One shows 1; Draw-Three
        // shows up to 3 fanned in X (matching the standard Klondike presentation).
        let render_start = if is_waste {
            let visible = match game.draw_mode() {
                DrawStockConfig::DrawOne => 1_usize,
                DrawStockConfig::DrawThree => 3_usize,
            };
            // Render one extra card so that the card sliding off the waste
            // during a draw animation is still present in the world at z=0
            // (hidden under the stack) rather than vanishing mid-tween.
            cards.len().saturating_sub(visible + 1)
        } else {
            0
        };

        let mut y_offset = 0.0_f32;
        let rendered_len = cards[render_start..].len();
        for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
            let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) {
                // When len > visible, slot 0 is a hidden buffer card kept at
                // x=0 to prevent a flash during the draw tween. When len ≤
                // visible (small pile), every card is visible and should fan
                // normally — no card is hidden, so the shift is 0.
                let visible = 3_usize;
                let hidden = rendered_len.saturating_sub(visible);
                slot.saturating_sub(hidden) as f32 * waste_fan_step
            } else {
                0.0
            };
            let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
            let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
            out.push(((card.clone(), *face_up), pos, z));
            if is_tableau {
                let step = if *face_up {
                    layout.tableau_fan_frac
                } else {
                    layout.tableau_facedown_fan_frac
                };
                y_offset -= layout.card_size.y * step;
            }
        }
    }
    out
}

pub(super) fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
    let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
    cards.extend(game.stock_cards());
    cards.extend(game.waste_cards());
    for foundation in solitaire_core::FOUNDATIONS {
        cards.extend(game.pile(KlondikePile::Foundation(foundation)));
    }
    for tableau in solitaire_core::TABLEAUS {
        cards.extend(game.pile(KlondikePile::Tableau(tableau)));
    }
    cards
}

#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_card_entity(
    commands: &mut Commands,
    card: &Card,
    face_up: bool,
    pos: Vec2,
    z: f32,
    layout: &Layout,
    back_colour: Color,
    color_blind: bool,
    high_contrast: bool,
    card_images: Option<&CardImageSet>,
    selected_back: usize,
    font_handle: Option<&Handle<Font>>,
) -> Entity {
    let sprite = card_sprite(
        card,
        face_up,
        layout.card_size,
        back_colour,
        card_images,
        selected_back,
    );

    let mut entity = commands.spawn((
        CardEntity { card: card.clone() },
        sprite,
        Transform::from_xyz(pos.x, pos.y, z),
        Visibility::default(),
    ));
    let entity_id = entity.id();
    // Every card gets a subtle drop-shadow child so the play surface reads
    // as physical instead of flat. Spawned in idle state; the drag-tracking
    // system retunes its offset / alpha when this card joins the dragged
    // stack.
    entity.with_children(|b| {
        add_card_shadow_child(b, layout.card_size);
    });
    // Every card gets a thin border frame so it reads as a distinct
    // rectangle against the dark felt, regardless of face state.
    entity.with_children(|b| {
        add_card_back_frame_child(b, layout.card_size);
    });
    // When PNG faces are loaded the rank/suit are baked into the image.
    // Only spawn the Text2d overlay in the solid-colour fallback (tests).
    // On Android we additionally spawn a large-print corner label even in
    // image mode so the rank/suit are legible at phone scale.
    if card_images.is_none() {
        entity.with_children(|b| {
            b.spawn((
                CardLabel,
                Text2d::new(label_for(card)),
                TextFont {
                    font_size: layout.card_size.x * FONT_SIZE_FRAC,
                    ..default()
                },
                TextColor(text_colour(card, color_blind, high_contrast)),
                Transform::from_xyz(0.0, 0.0, 0.01),
                label_visibility(face_up),
            ));
        });
    }
    if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
        entity.with_children(|b| {
            add_android_corner_label(
                b,
                card,
                face_up,
                layout.card_size,
                color_blind,
                high_contrast,
                font_handle,
            );
        });
    }
    // Record the appearance signature so subsequent `update_card_entity` calls
    // can skip rebuilding these children until one of the inputs changes.
    entity.insert(CardChildrenKey {
        face_up,
        card_size: layout.card_size,
        color_blind,
        high_contrast,
    });
    entity_id
}

#[allow(clippy::too_many_arguments)]
pub(super) fn update_card_entity(
    commands: &mut Commands,
    entity: Entity,
    card: &Card,
    face_up: bool,
    pos: Vec2,
    z: f32,
    layout: &Layout,
    slide_secs: f32,
    back_colour: Color,
    color_blind: bool,
    high_contrast: bool,
    cur: Vec3,
    has_card_animation: bool,
    existing_children_key: Option<CardChildrenKey>,
    card_images: Option<&CardImageSet>,
    selected_back: usize,
    font_handle: Option<&Handle<Font>>,
) {
    let target = Vec3::new(pos.x, pos.y, z);

    // Always refresh the visual appearance.
    commands.entity(entity).insert(card_sprite(
        card,
        face_up,
        layout.card_size,
        back_colour,
        card_images,
        selected_back,
    ));

    // Skip the snap/slide path entirely when a curve-based `CardAnimation`
    // is driving this card (e.g. the drag-rejection return tween). Writing
    // `Transform` here would race that animation each frame and cause a
    // visible jump. The animation system snaps the final position itself
    // when it completes.
    if !has_card_animation {
        // Slide to the new position when it differs meaningfully; snap otherwise.
        if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
            // Lift the card immediately on the first frame of the animation so
            // it never appears behind a card that is already resting at the
            // destination slot. `advance_card_anims` will maintain this lift
            // throughout the tween and snap to `target` (without lift) on
            // completion.
            let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
            commands
                .entity(entity)
                .insert(Transform::from_translation(start))
                .insert(CardAnim {
                    start,
                    target,
                    elapsed: 0.0,
                    duration: slide_secs,
                    delay: 0.0,
                });
        } else {
            commands
                .entity(entity)
                .remove::<CardAnim>()
                .insert(Transform::from_xyz(pos.x, pos.y, z));
        }
    }

    // Rebuild the card's child visuals (drop-shadow, border frame, and the
    // rank/suit label / large-print corner overlay) only when an input that
    // affects them actually changed. The child set depends solely on
    // `CardChildrenKey`; the face/back image is carried by the always-refreshed
    // `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
    // this on a position-only move avoids despawning and respawning the child
    // entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
    // `StateChangedEvent` — the spike that stuttered the slide animation on
    // high-resolution devices.
    let new_children_key = CardChildrenKey {
        face_up,
        card_size: layout.card_size,
        color_blind,
        high_contrast,
    };
    if existing_children_key != Some(new_children_key) {
        commands.entity(entity).despawn_related::<Children>();
        commands.entity(entity).with_children(|b| {
            add_card_shadow_child(b, layout.card_size);
        });
        commands.entity(entity).with_children(|b| {
            add_card_back_frame_child(b, layout.card_size);
        });
        if card_images.is_none() {
            commands.entity(entity).with_children(|b| {
                b.spawn((
                    CardLabel,
                    Text2d::new(label_for(card)),
                    TextFont {
                        font_size: layout.card_size.x * FONT_SIZE_FRAC,
                        ..default()
                    },
                    TextColor(text_colour(card, color_blind, high_contrast)),
                    Transform::from_xyz(0.0, 0.0, 0.01),
                    label_visibility(face_up),
                ));
            });
        }
        if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
            commands.entity(entity).with_children(|b| {
                add_android_corner_label(
                    b,
                    card,
                    face_up,
                    layout.card_size,
                    color_blind,
                    high_contrast,
                    font_handle,
                );
            });
        }
        commands.entity(entity).insert(new_children_key);
    }
}

