Demos/TarkovInventory/TarkovStashUI.cs
using System.Collections.Generic;
using Goo;
using Sandbox;
using Sandbox.UI;
using static Sandbox.TarkovInventory.StashTheme;

namespace Sandbox.TarkovInventory;

// dense stash grid with multi-cell drag-to-place, built on the Goo DnD primitive (single ancestor DropZone + positional DropLocation); lives in a full-screen modal toggled by "I", per-item-type solid tints (no typed gradient primitive yet).
public sealed class TarkovStashUI : GooPanel<Container>
{
    const int Cols = 10;
    const int Rows = 9;
    const string ToggleKey = "I";
    const string RotateKey = "R";
    const string SplitKey = "shift";    // engine default Run action binds this string (Input.Common.cs)
    const string CancelKey = "escape";

    // Sizes, colors, fonts and pure tile geometry live in StashTheme (imported via `using static`
    // above) so the controller and the StashTileBlob/SplitModalBlob presenters share one source.

    Grid<string>? _grid;
    Loadout? _loadout;
    AvatarView? _avatar;
    readonly Dictionary<string, StashItem> _items = new();
    readonly DragContext<string> _dnd = new();
    (int x, int y)? _hoverAnchor;

    bool _open = true;
    bool _togglePrev;

    // Per-item orientation (id -> rotated-90); missing key means upright. _dragRotated tracks the in-flight drag.
    readonly Dictionary<string, bool> _rotated = new();
    bool _dragRotated;
    bool _rotatePrev;
    string? _dragId;

    // split modal state: _splitId = open item (null = closed), _splitAmount = slider value, _splitHasRoom = free 1x1 existed at open (drives disabled-Split), _splitSeq = monotonic id counter.
    string? _splitId;
    int _splitAmount;
    bool _splitHasRoom;
    int _splitSeq;
    bool _escPrev;

    // rising-edge I-key toggle then base rebuild; OnUpdate ticks every frame, making it the natural place for input polling on a GooPanel.
    protected override void OnUpdate()
    {
        bool down = Sandbox.Input.Keyboard.Down( ToggleKey );
        if ( down && !_togglePrev ) { _open = !_open; Rebuild(); }
        _togglePrev = down;

        // Seed the drag orientation from the item's stored rotation when a drag begins; clear on end.
        if ( _dnd.IsDragging )
        {
            if ( _dragId != _dnd.Payload )
            {
                _dragId = _dnd.Payload;
                _dragRotated = _rotated.GetValueOrDefault( _dnd.Payload );
            }
            bool r = Sandbox.Input.Keyboard.Down( RotateKey );
            if ( r && !_rotatePrev ) { _dragRotated = !_dragRotated; Rebuild(); }
            _rotatePrev = r;
        }
        else
        {
            _dragId = null;
            _rotatePrev = false;
        }

        // Esc closes the split modal (rising-edge, same idiom as the I/R polls above).
        bool esc = Sandbox.Input.Keyboard.Down( CancelKey );
        if ( esc && !_escPrev && _splitId != null ) { _splitId = null; Rebuild(); }
        _escPrev = esc;

        base.OnUpdate();
    }

    // Only a stackable item with at least two in the pile can be split (gems; never magazines,
    // whose Count is rounds-loaded display with MaxStack 1). Mirrors stacking invariant #1.
    static bool IsSplittable( StashItem item ) => item.MaxStack > 1 && item.Count >= 2;

    // Shift + left-click on a splittable item opens the centered split modal. A pure click fires
    // OnClick (the engine starts a drag only on press-and-move, via OnDragStart), so this never
    // collides with dragging. The slider defaults to the round-up half of the stack.
    void TryOpenSplit( StashItem item )
    {
        if ( !Sandbox.Input.Keyboard.Down( SplitKey ) || !IsSplittable( item ) ) return;
        _splitId = item.Id;
        _splitAmount = StashItem.DefaultSplit( item.Count );
        _splitHasRoom = Stash().FindFirstFree( 1, 1 ) is not null;
        Rebuild();
    }

    // Ctors do not run on hotload; build lazily (NestedInventory idiom).
    Grid<string> Stash()
    {
        if ( _grid != null ) return _grid;
        _grid = new Grid<string>( Cols, Rows );
        foreach ( var item in StashCatalog.Dense() )
        {
            var spot = _grid.FindFirstFree( item.W, item.H );
            if ( spot is { } r && _grid.TryPlace( item.Id, r ) )
                _items[item.Id] = item;
        }
        return _grid;
    }

    // Lazily build the equip model (ctors do not run on hotload, same idiom as Stash()).
    Loadout Gear()
    {
        if ( _loadout != null ) return _loadout;
        // Helmet + weapon are functional (matching items exist in the stash); top/bottom/shoes are
        // labeled stubs with empty Accepts - they render as placeholders and reject every drop until
        // there is clothing/items to fill them. Adding a real item later is a one-line Accepts change.
        _loadout = new Loadout( new[]
        {
            new EquipSlot( "helmet", "Helmet", new HashSet<ItemKind> { ItemKind.Helmet } ),
            new EquipSlot( "top",    "Top",    new HashSet<ItemKind>() ),
            new EquipSlot( "bottom", "Bottom", new HashSet<ItemKind>() ),
            new EquipSlot( "shoes",  "Shoes",  new HashSet<ItemKind>() ),
            new EquipSlot( "weapon", "Weapon", new HashSet<ItemKind> { ItemKind.Rifle } ),
        } );
        return _loadout;
    }

    // Lazily build the avatar view (ctors do not run on hotload, same idiom as Gear()).
    AvatarView Avatar() => _avatar ??= new AvatarView();

    protected override Container Build()
    {
        // Closed: an empty root (no children). The idle->open transition diffs as keyed inserts
        // because every child below carries a Key (engine-fact: avoids the all-unkeyed length warning).
        if ( !_open )
            return new Container { Position = PositionMode.Absolute, PointerEvents = PointerEvents.None };

        var grid = Stash();
        Avatar().Sync( Gear() );   // avatar tracks the equip state on every rebuild

        return new Container
        {
            Position = PositionMode.Absolute,
            Left = 0f, Top = 0f,
            Width = Length.Percent( 100 ),
            Height = Length.Percent( 100 ),
            PointerEvents = PointerEvents.None,
            Children =
            {
                new Container
                {
                    Key = "backdrop",
                    Position = PositionMode.Absolute,
                    Left = 0f, Top = 0f,
                    Width = Length.Percent( 100 ),
                    Height = Length.Percent( 100 ),
                    BackgroundColor = Backdrop,
                    PointerEvents = PointerEvents.All,
                },
                Shell( grid ),
                Cell.Mount<DragLayer<string>>( key: "drag-layer", configure: l => l.Context = _dnd ),
                _splitId != null
                    ? SplitModal()
                    : new Container { Key = "split-slot", Position = PositionMode.Absolute, PointerEvents = PointerEvents.None },
            },
        };
    }

    // Centered split window over an additional backdrop dim. Shift+click a gem to open it; the slider
    // picks how many to peel off. Pure presentation lives in SplitModalBlob; this wires the state in.
    Container SplitModal() => SplitModalBlob.Build( new SplitModalBlob.Props(
        Item: _items[_splitId!],
        Amount: _splitAmount,
        HasRoom: _splitHasRoom,
        OnCancel: () => { _splitId = null; Rebuild(); },
        OnAmount: v => { _splitAmount = (int)v; Rebuild(); },
        OnSplit: DoSplit ) );

    // Peel _splitAmount off the open item into the first free 1x1 cell as a new stack, then close.
    void DoSplit()
    {
        if ( _splitId is not { } id || !_items.TryGetValue( id, out var src ) ) { _splitId = null; Rebuild(); return; }
        int n = _splitAmount;
        if ( n >= 1 && n < src.Count )
        {
            var grid = Stash();
            if ( grid.FindFirstFree( 1, 1 ) is { } spot )
            {
                var newId = $"{id}#{++_splitSeq}";
                var (reduced, peeled) = StashItem.Split( src, n, newId );
                if ( grid.TryPlace( peeled.Id, spot ) )
                {
                    _items[peeled.Id] = peeled;
                    _items[id] = reduced;
                }
            }
        }
        _splitId = null;
        Rebuild();
    }

    // Drop an item onto an equip slot. Type mismatch is rejected (DnD snaps the item back). The
    // incoming item's grid cells are freed first, then a displaced occupant is re-homed to the
    // first free cell; if it cannot be re-homed the swap is reverted, so no item is ever lost.
    void EquipDrop( string slotId, string id )
    {
        if ( !_items.TryGetValue( id, out var item ) ) return;
        var gear = Gear();
        var grid = Stash();
        if ( !gear.CanEquip( slotId, item.Kind ) ) return;
        if ( gear.Occupant( slotId ) == id ) { Rebuild(); return; }

        bool fromGrid = grid.Placed.TryGetValue( id, out var prev );
        grid.Remove( id );

        GridRect? spot = null;
        if ( gear.Occupant( slotId ) is { } old && _items.TryGetValue( old, out var oldItem ) )
        {
            var (w, h) = Eff( oldItem, _rotated.GetValueOrDefault( old ) );
            spot = grid.FindFirstFree( w, h );
            if ( spot is null )
            {
                if ( fromGrid ) grid.TryPlace( id, prev );
                return;
            }
        }

        if ( gear.SlotOf( id ) is { } from && from != slotId ) gear.Unequip( from );
        var result = gear.Equip( slotId, id, item.Kind );
        if ( result.Displaced is { } disp && spot is { } s ) grid.TryPlace( disp, s );
        _hoverAnchor = null;
        Rebuild();
    }

    // RPG paper-doll: relative body is a positioned ancestor (engine-fact-absolute-needs-positioned-ancestor) for slot anchors; scene frame must be a Column with FlexGrow ScenePanel, never Height=Percent(100) which collapses to zero (black avatar bug).
    Container CharacterBody()
    {
        var body = new Container
        {
            Key = "character-body",
            Position = PositionMode.Relative,
            FlexGrow = 1f,
            PointerEvents = PointerEvents.None,
        };

        // Central live avatar, inset from the edges to leave perimeter bands for the slots. No
        // backdrop: the scene clears to transparent (camera BackgroundColor alpha 0 in avatar.scene)
        // so only Terry draws, over the panel's worn-metal crate skin behind him.
        body.Children.Add( new Container
        {
            Key = "avatar-frame",
            Position = PositionMode.Absolute,
            Left = 116f, Right = 116f, Top = 112f, Bottom = 14f,
            FlexDirection = FlexDirection.Column,
            PointerEvents = PointerEvents.None,
            Children =
            {
                new Goo.ScenePanel
                {
                    Key = "avatar",
                    Scene = Avatar().Scene,
                    FlexGrow = 1f,
                    Width = Length.Percent( 100 ),
                },
            },
        } );

        // Helmet centred on the top edge; weapon centred on the right edge; the body slots
        // (top/bottom/shoes) stack down the left column. Anchors are starting values - nudge at UAT.
        if ( Gear().Slot( "helmet" ) is { } helmet )
            body.Children.Add( PlacedSlot( helmet, left: Length.Percent( 50 ), top: 10f, marginLeft: -EquipSlotSize / 2f ) );
        if ( Gear().Slot( "weapon" ) is { } weapon )
            body.Children.Add( PlacedSlot( weapon, right: 10f, top: Length.Percent( 50 ), marginTop: -EquipSlotSize / 2f ) );
        body.Children.Add( LeftColumn() );

        return body;
    }

    // A single equip slot pinned at an absolute anchor in the character body. Unset anchors stay
    // null (omitted) so callers pick any edge/centre combination; the optional margins centre a slot
    // on a percentage anchor (Left 50% + MarginLeft -half = horizontally centred).
    Container PlacedSlot( EquipSlot slot, Length? left = null, Length? top = null,
        Length? right = null, Length? bottom = null, float marginLeft = 0f, float marginTop = 0f )
        => new Container
        {
            Key = $"slot-anchor-{slot.Id}",
            Position = PositionMode.Absolute,
            Left = left, Top = top, Right = right, Bottom = bottom,
            MarginLeft = marginLeft, MarginTop = marginTop,
            Width = EquipSlotSize, Height = EquipSlotSize,
            PointerEvents = PointerEvents.All,
            Children = { EquipSlotZone( slot ) },
        };

    // The left column of body slots (top/bottom/shoes), stacked top-down along the left edge.
    Container LeftColumn()
    {
        var col = new Container
        {
            Key = "left-col",
            Position = PositionMode.Absolute,
            Left = 10f, Top = 112f,
            FlexDirection = FlexDirection.Column,
            Gap = 12f,
            PointerEvents = PointerEvents.All,
        };
        foreach ( var id in new[] { "top", "bottom", "shoes" } )
            if ( Gear().Slot( id ) is { } slot )
                col.Children.Add( new Container
                {
                    Key = $"slot-anchor-{id}",
                    Width = EquipSlotSize, Height = EquipSlotSize,
                    PointerEvents = PointerEvents.All,
                    Children = { EquipSlotZone( slot ) },
                } );
        return col;
    }

    CellElement EquipSlotZone( EquipSlot slot ) =>
        Cell.Mount<DropZone<string>>( key: $"equip-{slot.Id}", configure: z =>
        {
            z.Context = _dnd;
            z.OnDropPayload = ( id, _ ) => EquipDrop( slot.Id, id );
            z.Content = _ => EquipSlotTile( slot );
        } );

    Container EquipSlotTile( EquipSlot slot )
    {
        var bg = SlotBg;
        if ( _dnd.IsDragging && _dnd.HasPayload && _items.TryGetValue( _dnd.Payload, out var held ) )
            bg = slot.CanHold( held.Kind ) ? OkTint : BadTint;

        var tile = new Container
        {
            Key = "tile",
            Position = PositionMode.Relative,
            Width = EquipSlotSize, Height = EquipSlotSize,
            BackgroundColor = bg,
            BorderRadius = 6f,
            BorderWidth = 1f,
            BorderColor = ItemBdr,
            JustifyContent = Justify.Center,
            AlignItems = Align.Center,
            PointerEvents = PointerEvents.All,
        };
        if ( Gear().Occupant( slot.Id ) is { } id && _items.TryGetValue( id, out var item ) )
            tile.Children.Add( EquipOccupant( id, item ) );
        else
            tile.Children.Add( new Text( slot.Label.ToUpperInvariant() )   // faint label marks an empty slot
            {
                Key = "lbl",
                FontColor = Faint,
                FontFamily = CrateTheme.Font,
                FontSize = 12f,
                LetterSpacing = 1f,
            } );
        return tile;
    }

    // The equipped item's glyph, draggable back out via a DragSource over the shared context.
    CellElement EquipOccupant( string id, StashItem item ) =>
        Cell.Mount<DragSource<string>>( key: $"equipped-{id}", configure: s =>
        {
            s.Context = _dnd;
            s.Payload = id;
            s.Content = dragging => new Container
            {
                Key = "g",
                JustifyContent = Justify.Center,
                AlignItems = Align.Center,
                Width = EquipSlotSize, Height = EquipSlotSize,
                Opacity = dragging ? 0.35f : 1f,
                Children =
                {
                    new Goo.SvgPanel { Key = "svg", Path = item.Icon, Color = IconTint,
                        Width = EquipSlotSize - 20f, Height = EquipSlotSize - 20f },
                },
            };
            s.Ghost = () => StashTileBlob.Ghost( item, _dragRotated );
        } );

    // Two-column crate shell: a full-screen flex row over the backdrop with the paper-doll and stash centered. The stash never shrinks so the cursor-to-cell measurement holds; PointerEvents.None on the row lets the backdrop catch gap clicks.
    Container Shell( Grid<string> grid ) => new Container
    {
        Key = "shell",
        Position = PositionMode.Absolute,
        Left = 0f, Top = 0f,
        Width = Length.Percent( 100 ),
        Height = Length.Percent( 100 ),
        FlexDirection = FlexDirection.Row,
        AlignItems = Align.Stretch,
        JustifyContent = Justify.Center,
        Gap = 12f,
        Padding = 16f,
        PointerEvents = PointerEvents.None,
        Children =
        {
            CratePanel.Panel( "character-panel", "CHARACTER", CharacterBody(), width: 560f ),
            CratePanel.Panel( "stash-panel", "STASH", StashBody( grid ), hint: "[I] close" ),
        },
    };

    // Stash body: the exact-sized grid wrapper that hosts the DropZone. Size MUST stay
    // Cols*SlotSize x Rows*SlotSize (the DropZone measures this wrapper via e.This for the
    // cursor->cell mapping); centered in the panel, never stretched.
    Container StashBody( Grid<string> grid ) => new Container
    {
        Key = "stash-body",
        FlexGrow = 1f,
        AlignItems = Align.Center,
        JustifyContent = Justify.Center,
        Padding = 10f,
        PointerEvents = PointerEvents.None,
        Children =
        {
            new Container
            {
                Key = "grid-wrap",
                Width = Cols * SlotSize,
                Height = Rows * SlotSize,
                FlexShrink = 0,
                PointerEvents = PointerEvents.All,
                Children = { GridZone( grid ) },
            },
        },
    };

    CellElement GridZone( Grid<string> grid ) =>
        Cell.Mount<DropZone<string>>( key: "grid", configure: z =>
        {
            z.Context = _dnd;
            z.OnHover = loc =>
                _hoverAnchor = loc is { } l ? GridGeometry.CellAt( l.Local, l.ZoneSize, Cols, Rows ) : null;
            z.OnDropPayload = ( id, loc ) =>
            {
                var (cx, cy) = GridGeometry.CellAt( loc.Local, loc.ZoneSize, Cols, Rows );
                var src = _items[id];

                // An equipped item dragged back to the grid: unequip, then place at the target cell
                // (or the first free spot if the target is taken). If the grid is full, re-equip.
                if ( Gear().SlotOf( id ) is { } slot )
                {
                    Gear().Unequip( slot );
                    var (ew, eh) = Eff( src, _dragRotated );
                    if ( grid.TryPlace( id, new GridRect( cx, cy, ew, eh ) )
                         || (grid.FindFirstFree( ew, eh ) is { } free && grid.TryPlace( id, free )) )
                        _rotated[id] = _dragRotated;
                    else
                        Gear().Equip( slot, id, src.Kind );
                    _hoverAnchor = null;
                    Rebuild();
                    return;
                }

                // Stack onto a same-kind stackable item under the anchor cell instead of moving.
                if ( grid.ItemAt( cx, cy ) is { } tid && tid != id
                     && _items.TryGetValue( tid, out var tgt ) && StashItem.CanStack( src, tgt )
                     && tgt.Count < tgt.MaxStack )
                {
                    var (merged, leftover) = StashItem.Stack( src, tgt );
                    _items[tid] = merged;
                    if ( leftover > 0 )
                    {
                        _items[id] = src with { Count = leftover };   // partial: source stays put with the remainder
                    }
                    else
                    {
                        grid.Remove( id );                            // full merge: source consumed
                        _items.Remove( id );
                        _rotated.Remove( id );
                    }
                    _hoverAnchor = null;
                    Rebuild();
                    return;
                }

                var (w, h) = Eff( src, _dragRotated );
                if ( grid.TryMove( id, new GridRect( cx, cy, w, h ) ) )
                    _rotated[id] = _dragRotated;
                _hoverAnchor = null;
                Rebuild();
            };
            z.Content = _ => GridFrame( grid );
        } );

    Container GridFrame( Grid<string> grid )
    {
        // relative + sized so e.This.Box.Rect equals the rendered grid (fixes cursor->cell mapping) and keeps a positioned ancestor for absolute cell tiles (engine-fact-absolute-needs-positioned-ancestor).
        var frame = new Container
        {
            Key = "frame",
            Position = PositionMode.Relative,
            Width = Cols * SlotSize,
            Height = Rows * SlotSize,
            BackgroundColor = GridBg,
            BorderRadius = 6f,
            PointerEvents = PointerEvents.All,
        };

        // while dragging, cells paint above items so every square is the topmost drop target (engine-fact-sbox-ui-drop-dispatch: dragenter fires on DropTarget change); idle, items paint above for a clean look.
        void AddCells()
        {
            for ( int y = 0; y < Rows; y++ )
            for ( int x = 0; x < Cols; x++ )
                frame.Children.Add( CellTile( x, y, grid ) );
        }
        void AddItems()
        {
            foreach ( var kv in grid.Placed )
                frame.Children.Add( ItemView( kv.Key, kv.Value ) );
        }

        if ( _dnd.IsDragging ) { AddItems(); AddCells(); }
        else { AddCells(); AddItems(); }

        return frame;
    }

    Container CellTile( int x, int y, Grid<string> grid )
    {
        var bg = CellBg;
        if ( _dnd.IsDragging && _hoverAnchor is { } a && InFootprint( x, y, a, out var rect ) )
        {
            if ( grid.CanPlaceIgnoring( rect, _dnd.Payload ) )
                bg = OkTint;
            else
                bg = IsMergeTarget( grid, x, y ) ? MergeTint : BadTint;
        }

        // Inset by 1px so the dark frame shows through as grid lines (no border props needed).
        return new Container
        {
            Key = $"cell-{x}-{y}",
            Position = PositionMode.Absolute,
            Left = x * SlotSize + 1f, Top = y * SlotSize + 1f,
            Width = SlotSize - 2f, Height = SlotSize - 2f,
            BackgroundColor = bg,
            PointerEvents = PointerEvents.All,  // makes each cell a DropTarget → per-cell enter
        };
    }

    bool InFootprint( int x, int y, (int x, int y) anchor, out GridRect rect )
    {
        int w = 1, h = 1;
        if ( _dnd.HasPayload && _items.TryGetValue( _dnd.Payload, out var item ) )
            (w, h) = Eff( item, _dragRotated );
        rect = new GridRect( anchor.x, anchor.y, w, h );
        return x >= anchor.x && x < anchor.x + w && y >= anchor.y && y < anchor.y + h;
    }

    // True when the dragged payload would stack onto the item occupying (x, y) and that stack has room.
    bool IsMergeTarget( Grid<string> grid, int x, int y )
    {
        if ( !_dnd.HasPayload || !_items.TryGetValue( _dnd.Payload, out var src ) ) return false;
        if ( grid.ItemAt( x, y ) is not { } tid || tid == _dnd.Payload ) return false;
        return _items.TryGetValue( tid, out var tgt ) && StashItem.CanStack( src, tgt ) && tgt.Count < tgt.MaxStack;
    }

    Container ItemView( string id, GridRect rect )
    {
        var item = _items[id];
        return new Container
        {
            Key = $"slot-{id}",
            Position = PositionMode.Absolute,
            Left = rect.X * SlotSize, Top = rect.Y * SlotSize,
            PointerEvents = PointerEvents.All,
            Children =
            {
                Cell.Mount<DragSource<string>>( key: $"item-{id}", configure: s =>
                {
                    s.Context = _dnd;
                    s.Payload = id;
                    s.Content = dragging => StashTileBlob.Tile( item, rect, dragging, _rotated.GetValueOrDefault( id ), _ => TryOpenSplit( item ) );
                    s.Ghost = () => StashTileBlob.Ghost( item, _dragRotated );
                } ),
            },
        };
    }
}