using Microsoft.Graphics.Canvas;                         // CanvasBlend, CanvasBitmap
using Microsoft.Graphics.Canvas.UI;
using Microsoft.Graphics.Canvas.UI.Xaml;                 // CanvasAnimatedControl
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;                                   // Vector2, Matrix3x2
using System.Runtime.InteropServices.WindowsRuntime;      // AsAsyncAction + AsRandomAccessStream
using Windows.Foundation;                                // Rect
using Windows.UI;                                        // Color/Colors (Win2D)

namespace Sonex.Client.Controls;

public sealed partial class ForceDirectedAnimation : UserControl
{
    // =====================================================================
    //  DEPENDENCY PROPERTIES
    // =====================================================================

    // ====== DP (kształt obszaru: 4 rogi, wartości "od dołu") ======
    public static readonly DependencyProperty TopLeftYProperty =
        DependencyProperty.Register(nameof(TopLeftY), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(60d, OnSettingsChanged));

    public static readonly DependencyProperty TopRightYProperty =
        DependencyProperty.Register(nameof(TopRightY), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(90d, OnSettingsChanged));

    public static readonly DependencyProperty BottomLeftYProperty =
        DependencyProperty.Register(nameof(BottomLeftY), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(360d, OnSettingsChanged));

    public static readonly DependencyProperty BottomRightYProperty =
        DependencyProperty.Register(nameof(BottomRightY), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(300d, OnSettingsChanged));

    // niewidoczne “ściany” poza obrysem (px)
    public static readonly DependencyProperty InvisibleBouncePadProperty =
        DependencyProperty.Register(nameof(InvisibleBouncePad), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(40d, OnSettingsChanged));

    // tłumienie odbicia (0..1) (legacy)
    public static readonly DependencyProperty BounceDampingProperty =
        DependencyProperty.Register(nameof(BounceDamping), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.98d, OnSettingsChanged));

    // ====== DP (ruch / ilość) ======
    public static readonly DependencyProperty ScrollSpeedProperty =
        DependencyProperty.Register(nameof(ScrollSpeed), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(120d, OnSettingsChanged));   // px/s w lewo

    public static readonly DependencyProperty NodeSpawnRateProperty =
        DependencyProperty.Register(nameof(NodeSpawnRate), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(3d, OnSettingsChanged));     // nodes/s

    public static readonly DependencyProperty MaxNodesProperty =
        DependencyProperty.Register(nameof(MaxNodes), typeof(int), typeof(ForceDirectedAnimation),
            new PropertyMetadata(120, OnSettingsChanged));

    public static readonly DependencyProperty MinNodeRadiusProperty =
        DependencyProperty.Register(nameof(MinNodeRadius), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(4d, OnSettingsChanged));

    public static readonly DependencyProperty MaxNodeRadiusProperty =
        DependencyProperty.Register(nameof(MaxNodeRadius), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(10d, OnSettingsChanged));

    // ====== DP (kolizje - podstawowe) ======
    public static readonly DependencyProperty CollisionPaddingProperty =
        DependencyProperty.Register(nameof(CollisionPadding), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(1.5d, OnSettingsChanged));

    public static readonly DependencyProperty CollisionIterationsProperty =
        DependencyProperty.Register(nameof(CollisionIterations), typeof(int), typeof(ForceDirectedAnimation),
            new PropertyMetadata(1, OnSettingsChanged));

    public static readonly DependencyProperty AppearWidthProperty =
        DependencyProperty.Register(nameof(AppearWidth), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(120d, OnSettingsChanged));   // px (fade-in z prawej)

    // ====== DP (kolory) ======
    public static readonly DependencyProperty NodeColorAProperty =
        DependencyProperty.Register(nameof(NodeColorA), typeof(Color), typeof(ForceDirectedAnimation),
            new PropertyMetadata(Color.FromArgb(255, 210, 210, 210), OnSettingsChanged));

    public static readonly DependencyProperty NodeColorBProperty =
        DependencyProperty.Register(nameof(NodeColorB), typeof(Color), typeof(ForceDirectedAnimation),
            new PropertyMetadata(Color.FromArgb(255, 242, 194, 48), OnSettingsChanged));

    public static readonly DependencyProperty ColorAWeightProperty =
        DependencyProperty.Register(nameof(ColorAWeight), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.85d, OnSettingsChanged));  // 0..1

    // ====== DP (ikony) ======
    public static readonly DependencyProperty IconUrisProperty =
        DependencyProperty.Register(nameof(IconUris), typeof(string), typeof(ForceDirectedAnimation),
            new PropertyMetadata(string.Empty, OnSettingsChanged));

    public static readonly DependencyProperty IconMinDiameterProperty =
        DependencyProperty.Register(nameof(IconMinDiameter), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(18d, OnSettingsChanged));

    public static readonly DependencyProperty IconScaleProperty =
        DependencyProperty.Register(nameof(IconScale), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.72d, OnSettingsChanged));

    // ====== LOSOWA ROTACJA IKON ======
    public static readonly DependencyProperty RandomIconRotationProperty =
        DependencyProperty.Register(nameof(RandomIconRotation), typeof(bool), typeof(ForceDirectedAnimation),
            new PropertyMetadata(false, OnSettingsChanged));

    // ====== TOGGLES RENDERING ======
    public static readonly DependencyProperty DrawEdgesProperty =
        DependencyProperty.Register(nameof(DrawEdges), typeof(bool), typeof(ForceDirectedAnimation),
            new PropertyMetadata(true, OnSettingsChanged));

    public static readonly DependencyProperty FillNodesProperty =
        DependencyProperty.Register(nameof(FillNodes), typeof(bool), typeof(ForceDirectedAnimation),
            new PropertyMetadata(true, OnSettingsChanged));

    public static readonly DependencyProperty NodesOpocityProperty =
        DependencyProperty.Register(nameof(NodesOpocity), typeof(bool), typeof(ForceDirectedAnimation),
            new PropertyMetadata(true, OnSettingsChanged));

    // ====== PULSE ICON WIDTH (butterfly wings) ======
    public static readonly DependencyProperty PulseIconWidthProperty =
        DependencyProperty.Register(nameof(PulseIconWidth), typeof(bool), typeof(ForceDirectedAnimation),
            new PropertyMetadata(false, OnSettingsChanged));

    public static readonly DependencyProperty PulseIconWidthAmplitudeProperty =
        DependencyProperty.Register(nameof(PulseIconWidthAmplitude), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.22d, OnSettingsChanged));   // 0..0.6

    public static readonly DependencyProperty PulseIconWidthFrequencyProperty =
        DependencyProperty.Register(nameof(PulseIconWidthFrequency), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(6.0d, OnSettingsChanged));    // Hz

    // ====== ICON FIREWORK (OFF -> EXPAND -> HOLD+FLARE -> FADE) ======
    public static readonly DependencyProperty FireworkIconBurstProperty =
        DependencyProperty.Register(nameof(FireworkIconBurst), typeof(bool), typeof(ForceDirectedAnimation),
            new PropertyMetadata(false, OnSettingsChanged));

    public static readonly DependencyProperty FireworkOffDurationProperty =
        DependencyProperty.Register(nameof(FireworkOffDuration), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.80d, OnSettingsChanged)); // s

    public static readonly DependencyProperty FireworkExpandDurationProperty =
        DependencyProperty.Register(nameof(FireworkExpandDuration), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.10d, OnSettingsChanged)); // s

    public static readonly DependencyProperty FireworkHoldDurationProperty =
        DependencyProperty.Register(nameof(FireworkHoldDuration), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.22d, OnSettingsChanged)); // s

    public static readonly DependencyProperty FireworkFadeOutDurationProperty =
        DependencyProperty.Register(nameof(FireworkFadeOutDuration), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.18d, OnSettingsChanged)); // s

    public static readonly DependencyProperty FireworkMinScaleProperty =
        DependencyProperty.Register(nameof(FireworkMinScale), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.06d, OnSettingsChanged)); // 0.01..1

    public static readonly DependencyProperty FireworkTargetScaleProperty =
        DependencyProperty.Register(nameof(FireworkTargetScale), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(1.00d, OnSettingsChanged)); // 0.2..10

    public static readonly DependencyProperty FireworkFlareAmplitudeProperty =
        DependencyProperty.Register(nameof(FireworkFlareAmplitude), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.65d, OnSettingsChanged)); // 0..2.5

    public static readonly DependencyProperty FireworkFlareFrequencyProperty =
        DependencyProperty.Register(nameof(FireworkFlareFrequency), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(10.0d, OnSettingsChanged)); // Hz

    // ====== BACKGROUND (LIGHT/DARK) ======
    public static readonly DependencyProperty BackgroundImageUriProperty =
        DependencyProperty.Register(nameof(BackgroundImageUri), typeof(string), typeof(ForceDirectedAnimation),
            new PropertyMetadata(string.Empty, OnSettingsChanged));

    public static readonly DependencyProperty BackgroundImageUriLightProperty =
        DependencyProperty.Register(nameof(BackgroundImageUriLight), typeof(string), typeof(ForceDirectedAnimation),
            new PropertyMetadata(string.Empty, OnSettingsChanged));

    public static readonly DependencyProperty BackgroundImageUriDarkProperty =
        DependencyProperty.Register(nameof(BackgroundImageUriDark), typeof(string), typeof(ForceDirectedAnimation),
            new PropertyMetadata(string.Empty, OnSettingsChanged));

    // =====================================================================
    //  PARAMETRY KOLIZJI (SMOOTH COLLISION TUNING)
    // =====================================================================

    public static readonly DependencyProperty CollisionPositionCorrectionProperty =
        DependencyProperty.Register(nameof(CollisionPositionCorrection), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(1.0d, OnSettingsChanged));

    public static readonly DependencyProperty CollisionSlopPxProperty =
        DependencyProperty.Register(nameof(CollisionSlopPx), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.40d, OnSettingsChanged));

    public static readonly DependencyProperty CollisionMaxCorrectionPxProperty =
        DependencyProperty.Register(nameof(CollisionMaxCorrectionPx), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(10.0d, OnSettingsChanged));

    public static readonly DependencyProperty CollisionSeparationSpringProperty =
        DependencyProperty.Register(nameof(CollisionSeparationSpring), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(6.0d, OnSettingsChanged));

    public static readonly DependencyProperty CollisionImpulseDampingProperty =
        DependencyProperty.Register(nameof(CollisionImpulseDamping), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.03d, OnSettingsChanged));

    public static readonly DependencyProperty CollisionTangentFrictionProperty =
        DependencyProperty.Register(nameof(CollisionTangentFriction), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.01d, OnSettingsChanged));

    // =====================================================================
    //  PARAMETRY POWIĄZAŃ (CONNECTIVITY)
    // =====================================================================

    public static readonly DependencyProperty MinLinksPerNodeProperty =
        DependencyProperty.Register(nameof(MinLinksPerNode), typeof(int), typeof(ForceDirectedAnimation),
            new PropertyMetadata(1, OnSettingsChanged));

    public static readonly DependencyProperty MaxLinksPerNodeProperty =
        DependencyProperty.Register(nameof(MaxLinksPerNode), typeof(int), typeof(ForceDirectedAnimation),
            new PropertyMetadata(3, OnSettingsChanged));

    public static readonly DependencyProperty EnableExtraLinkProperty =
        DependencyProperty.Register(nameof(EnableExtraLink), typeof(bool), typeof(ForceDirectedAnimation),
            new PropertyMetadata(true, OnSettingsChanged));

    public static readonly DependencyProperty ExtraLinkChanceProperty =
        DependencyProperty.Register(nameof(ExtraLinkChance), typeof(double), typeof(ForceDirectedAnimation),
            new PropertyMetadata(0.25d, OnSettingsChanged));

    // =====================================================================
    //  PUBLIC PROPERTIES
    // =====================================================================

    public double TopLeftY { get => (double)GetValue(TopLeftYProperty); set => SetValue(TopLeftYProperty, value); }
    public double TopRightY { get => (double)GetValue(TopRightYProperty); set => SetValue(TopRightYProperty, value); }
    public double BottomLeftY { get => (double)GetValue(BottomLeftYProperty); set => SetValue(BottomLeftYProperty, value); }
    public double BottomRightY { get => (double)GetValue(BottomRightYProperty); set => SetValue(BottomRightYProperty, value); }

    public double InvisibleBouncePad { get => (double)GetValue(InvisibleBouncePadProperty); set => SetValue(InvisibleBouncePadProperty, value); }
    public double BounceDamping { get => (double)GetValue(BounceDampingProperty); set => SetValue(BounceDampingProperty, value); }

    public double ScrollSpeed { get => (double)GetValue(ScrollSpeedProperty); set => SetValue(ScrollSpeedProperty, value); }
    public double NodeSpawnRate { get => (double)GetValue(NodeSpawnRateProperty); set => SetValue(NodeSpawnRateProperty, value); }
    public int MaxNodes { get => (int)GetValue(MaxNodesProperty); set => SetValue(MaxNodesProperty, value); }

    public double MinNodeRadius { get => (double)GetValue(MinNodeRadiusProperty); set => SetValue(MinNodeRadiusProperty, value); }
    public double MaxNodeRadius { get => (double)GetValue(MaxNodeRadiusProperty); set => SetValue(MaxNodeRadiusProperty, value); }

    public double CollisionPadding { get => (double)GetValue(CollisionPaddingProperty); set => SetValue(CollisionPaddingProperty, value); }
    public int CollisionIterations { get => (int)GetValue(CollisionIterationsProperty); set => SetValue(CollisionIterationsProperty, value); }

    public double AppearWidth { get => (double)GetValue(AppearWidthProperty); set => SetValue(AppearWidthProperty, value); }

    public Color NodeColorA { get => (Color)GetValue(NodeColorAProperty); set => SetValue(NodeColorAProperty, value); }
    public Color NodeColorB { get => (Color)GetValue(NodeColorBProperty); set => SetValue(NodeColorBProperty, value); }
    public double ColorAWeight { get => (double)GetValue(ColorAWeightProperty); set => SetValue(ColorAWeightProperty, value); }

    public string IconUris { get => (string)GetValue(IconUrisProperty); set => SetValue(IconUrisProperty, value); }
    public double IconMinDiameter { get => (double)GetValue(IconMinDiameterProperty); set => SetValue(IconMinDiameterProperty, value); }
    public double IconScale { get => (double)GetValue(IconScaleProperty); set => SetValue(IconScaleProperty, value); }

    public bool RandomIconRotation { get => (bool)GetValue(RandomIconRotationProperty); set => SetValue(RandomIconRotationProperty, value); }

    public bool DrawEdges { get => (bool)GetValue(DrawEdgesProperty); set => SetValue(DrawEdgesProperty, value); }
    public bool FillNodes { get => (bool)GetValue(FillNodesProperty); set => SetValue(FillNodesProperty, value); }
    public bool NodesOpocity { get => (bool)GetValue(NodesOpocityProperty); set => SetValue(NodesOpocityProperty, value); }

    public bool PulseIconWidth { get => (bool)GetValue(PulseIconWidthProperty); set => SetValue(PulseIconWidthProperty, value); }
    public double PulseIconWidthAmplitude { get => (double)GetValue(PulseIconWidthAmplitudeProperty); set => SetValue(PulseIconWidthAmplitudeProperty, value); }
    public double PulseIconWidthFrequency { get => (double)GetValue(PulseIconWidthFrequencyProperty); set => SetValue(PulseIconWidthFrequencyProperty, value); }

    public bool FireworkIconBurst { get => (bool)GetValue(FireworkIconBurstProperty); set => SetValue(FireworkIconBurstProperty, value); }
    public double FireworkOffDuration { get => (double)GetValue(FireworkOffDurationProperty); set => SetValue(FireworkOffDurationProperty, value); }
    public double FireworkExpandDuration { get => (double)GetValue(FireworkExpandDurationProperty); set => SetValue(FireworkExpandDurationProperty, value); }
    public double FireworkHoldDuration { get => (double)GetValue(FireworkHoldDurationProperty); set => SetValue(FireworkHoldDurationProperty, value); }
    public double FireworkFadeOutDuration { get => (double)GetValue(FireworkFadeOutDurationProperty); set => SetValue(FireworkFadeOutDurationProperty, value); }
    public double FireworkMinScale { get => (double)GetValue(FireworkMinScaleProperty); set => SetValue(FireworkMinScaleProperty, value); }
    public double FireworkTargetScale { get => (double)GetValue(FireworkTargetScaleProperty); set => SetValue(FireworkTargetScaleProperty, value); }
    public double FireworkFlareAmplitude { get => (double)GetValue(FireworkFlareAmplitudeProperty); set => SetValue(FireworkFlareAmplitudeProperty, value); }
    public double FireworkFlareFrequency { get => (double)GetValue(FireworkFlareFrequencyProperty); set => SetValue(FireworkFlareFrequencyProperty, value); }

    public string BackgroundImageUri { get => (string)GetValue(BackgroundImageUriProperty); set => SetValue(BackgroundImageUriProperty, value); }
    public string BackgroundImageUriLight { get => (string)GetValue(BackgroundImageUriLightProperty); set => SetValue(BackgroundImageUriLightProperty, value); }
    public string BackgroundImageUriDark { get => (string)GetValue(BackgroundImageUriDarkProperty); set => SetValue(BackgroundImageUriDarkProperty, value); }

    public double CollisionPositionCorrection { get => (double)GetValue(CollisionPositionCorrectionProperty); set => SetValue(CollisionPositionCorrectionProperty, value); }
    public double CollisionSlopPx { get => (double)GetValue(CollisionSlopPxProperty); set => SetValue(CollisionSlopPxProperty, value); }
    public double CollisionMaxCorrectionPx { get => (double)GetValue(CollisionMaxCorrectionPxProperty); set => SetValue(CollisionMaxCorrectionPxProperty, value); }
    public double CollisionSeparationSpring { get => (double)GetValue(CollisionSeparationSpringProperty); set => SetValue(CollisionSeparationSpringProperty, value); }
    public double CollisionImpulseDamping { get => (double)GetValue(CollisionImpulseDampingProperty); set => SetValue(CollisionImpulseDampingProperty, value); }
    public double CollisionTangentFriction { get => (double)GetValue(CollisionTangentFrictionProperty); set => SetValue(CollisionTangentFrictionProperty, value); }

    public int MinLinksPerNode { get => (int)GetValue(MinLinksPerNodeProperty); set => SetValue(MinLinksPerNodeProperty, value); }
    public int MaxLinksPerNode { get => (int)GetValue(MaxLinksPerNodeProperty); set => SetValue(MaxLinksPerNodeProperty, value); }
    public bool EnableExtraLink { get => (bool)GetValue(EnableExtraLinkProperty); set => SetValue(EnableExtraLinkProperty, value); }
    public double ExtraLinkChance { get => (double)GetValue(ExtraLinkChanceProperty); set => SetValue(ExtraLinkChanceProperty, value); }

    // =====================================================================
    //  DEFAULTS + RESET
    // =====================================================================

    private const double DefaultTopLeftY = 60d;
    private const double DefaultTopRightY = 90d;
    private const double DefaultBottomLeftY = 360d;
    private const double DefaultBottomRightY = 300d;

    private const double DefaultInvisibleBouncePad = 40d;
    private const double DefaultBounceDamping = 0.98d;

    private const double DefaultScrollSpeed = 120d;
    private const double DefaultNodeSpawnRate = 3d;
    private const int DefaultMaxNodes = 120;

    private const double DefaultMinNodeRadius = 4d;
    private const double DefaultMaxNodeRadius = 10d;

    private const double DefaultCollisionPadding = 1.5d;
    private const int DefaultCollisionIterations = 1;

    private const double DefaultAppearWidth = 120d;

    private static readonly Color DefaultNodeColorA = Color.FromArgb(255, 210, 210, 210);
    private static readonly Color DefaultNodeColorB = Color.FromArgb(255, 242, 194, 48);
    private const double DefaultColorAWeight = 0.85d;

    private const string DefaultIconUris = "";
    private const double DefaultIconMinDiameter = 18d;
    private const double DefaultIconScale = 0.72d;

    private const bool DefaultRandomIconRotation = false;

    private const bool DefaultDrawEdges = true;
    private const bool DefaultFillNodes = true;
    private const bool DefaultNodesOpocity = true;

    private const bool DefaultPulseIconWidth = false;
    private const double DefaultPulseIconWidthAmplitude = 0.22d;
    private const double DefaultPulseIconWidthFrequency = 6.0d;

    private const bool DefaultFireworkIconBurst = false;
    private const double DefaultFireworkOffDuration = 0.80d;
    private const double DefaultFireworkExpandDuration = 0.10d;
    private const double DefaultFireworkHoldDuration = 0.22d;
    private const double DefaultFireworkFadeOutDuration = 0.18d;
    private const double DefaultFireworkMinScale = 0.06d;
    private const double DefaultFireworkTargetScale = 1.00d;
    private const double DefaultFireworkFlareAmplitude = 0.65d;
    private const double DefaultFireworkFlareFrequency = 10.0d;

    private const string DefaultBackgroundImageUri = "";
    private const string DefaultBackgroundImageUriLight = "";
    private const string DefaultBackgroundImageUriDark = "";

    private const double DefaultCollisionPositionCorrection = 1.0d;
    private const double DefaultCollisionSlopPx = 0.40d;
    private const double DefaultCollisionMaxCorrectionPx = 10.0d;
    private const double DefaultCollisionSeparationSpring = 6.0d;
    private const double DefaultCollisionImpulseDamping = 0.03d;
    private const double DefaultCollisionTangentFriction = 0.01d;

    private const int DefaultMinLinksPerNode = 1;
    private const int DefaultMaxLinksPerNode = 3;
    private const bool DefaultEnableExtraLink = true;
    private const double DefaultExtraLinkChance = 0.25d;

    /// <summary>
    /// Ustawia wszystkie DP na wartości domyślne.
    /// </summary>
    public void ResetParametersToDefaults(bool syncCache = true, bool markResourcesDirty = true)
    {
        SetValue(TopLeftYProperty, DefaultTopLeftY);
        SetValue(TopRightYProperty, DefaultTopRightY);
        SetValue(BottomLeftYProperty, DefaultBottomLeftY);
        SetValue(BottomRightYProperty, DefaultBottomRightY);

        SetValue(InvisibleBouncePadProperty, DefaultInvisibleBouncePad);
        SetValue(BounceDampingProperty, DefaultBounceDamping);

        SetValue(ScrollSpeedProperty, DefaultScrollSpeed);
        SetValue(NodeSpawnRateProperty, DefaultNodeSpawnRate);
        SetValue(MaxNodesProperty, DefaultMaxNodes);

        SetValue(MinNodeRadiusProperty, DefaultMinNodeRadius);
        SetValue(MaxNodeRadiusProperty, DefaultMaxNodeRadius);

        SetValue(CollisionPaddingProperty, DefaultCollisionPadding);
        SetValue(CollisionIterationsProperty, DefaultCollisionIterations);
        SetValue(AppearWidthProperty, DefaultAppearWidth);

        SetValue(NodeColorAProperty, DefaultNodeColorA);
        SetValue(NodeColorBProperty, DefaultNodeColorB);
        SetValue(ColorAWeightProperty, DefaultColorAWeight);

        SetValue(IconUrisProperty, DefaultIconUris);
        SetValue(IconMinDiameterProperty, DefaultIconMinDiameter);
        SetValue(IconScaleProperty, DefaultIconScale);
        SetValue(RandomIconRotationProperty, DefaultRandomIconRotation);

        SetValue(DrawEdgesProperty, DefaultDrawEdges);
        SetValue(FillNodesProperty, DefaultFillNodes);
        SetValue(NodesOpocityProperty, DefaultNodesOpocity);

        SetValue(PulseIconWidthProperty, DefaultPulseIconWidth);
        SetValue(PulseIconWidthAmplitudeProperty, DefaultPulseIconWidthAmplitude);
        SetValue(PulseIconWidthFrequencyProperty, DefaultPulseIconWidthFrequency);

        SetValue(FireworkIconBurstProperty, DefaultFireworkIconBurst);
        SetValue(FireworkOffDurationProperty, DefaultFireworkOffDuration);
        SetValue(FireworkExpandDurationProperty, DefaultFireworkExpandDuration);
        SetValue(FireworkHoldDurationProperty, DefaultFireworkHoldDuration);
        SetValue(FireworkFadeOutDurationProperty, DefaultFireworkFadeOutDuration);
        SetValue(FireworkMinScaleProperty, DefaultFireworkMinScale);
        SetValue(FireworkTargetScaleProperty, DefaultFireworkTargetScale);
        SetValue(FireworkFlareAmplitudeProperty, DefaultFireworkFlareAmplitude);
        SetValue(FireworkFlareFrequencyProperty, DefaultFireworkFlareFrequency);

        SetValue(BackgroundImageUriProperty, DefaultBackgroundImageUri);
        SetValue(BackgroundImageUriLightProperty, DefaultBackgroundImageUriLight);
        SetValue(BackgroundImageUriDarkProperty, DefaultBackgroundImageUriDark);

        SetValue(CollisionPositionCorrectionProperty, DefaultCollisionPositionCorrection);
        SetValue(CollisionSlopPxProperty, DefaultCollisionSlopPx);
        SetValue(CollisionMaxCorrectionPxProperty, DefaultCollisionMaxCorrectionPx);
        SetValue(CollisionSeparationSpringProperty, DefaultCollisionSeparationSpring);
        SetValue(CollisionImpulseDampingProperty, DefaultCollisionImpulseDamping);
        SetValue(CollisionTangentFrictionProperty, DefaultCollisionTangentFriction);

        SetValue(MinLinksPerNodeProperty, DefaultMinLinksPerNode);
        SetValue(MaxLinksPerNodeProperty, DefaultMaxLinksPerNode);
        SetValue(EnableExtraLinkProperty, DefaultEnableExtraLink);
        SetValue(ExtraLinkChanceProperty, DefaultExtraLinkChance);

        if (markResourcesDirty)
        {
            _iconsDirty = true;
            _bgDirty = true;

            _iconsLoading = false;
            _iconsLoadAttempted = false;
            _pendingIconReassign = false;
        }

        if (syncCache)
            SyncSettingsCache();
    }

    // =====================================================================
    //  CACHE (DP -> szybkie pola)
    // =====================================================================

    private volatile bool _nodesOpacity = true;
    private volatile float _topLeftY = 60f, _topRightY = 90f, _bottomLeftY = 360f, _bottomRightY = 300f;
    private volatile float _invisibleBouncePad = 40f;
    private volatile float _bounceDamping = 0.98f;

    private volatile float _scrollSpeed = 120f;
    private volatile float _nodeSpawnRate = 3f;
    private volatile int _maxNodes = 120;

    private volatile float _minR = 4f;
    private volatile float _maxR = 10f;

    private volatile float _collisionPadding = 1.5f;
    private volatile int _collisionIterations = 1;

    private volatile float _appearWidth = 120f;

    private volatile uint _colorA = 0xFFD2D2D2;
    private volatile uint _colorB = 0xFFF2C230;
    private volatile float _colorAWeight = 0.85f;

    private volatile float _iconMinDiameter = 18f;
    private volatile float _iconScale = 0.72f;

    private volatile bool _randomIconRotation = false;

    private volatile bool _drawEdges = true;
    private volatile bool _fillNodes = true;

    private volatile bool _pulseIconWidth = false;
    private volatile float _pulseAmp = 0.22f;
    private volatile float _pulseFreq = 6.0f;

    private volatile bool _fireworkBurst = false;
    private volatile float _fwOff = 0.80f, _fwExpand = 0.10f, _fwHold = 0.22f, _fwFade = 0.18f;
    private volatile float _fwMinScale = 0.06f, _fwTargetScale = 1.00f;
    private volatile float _fwFlareAmp = 0.65f, _fwFlareFreq = 10.0f;

    private volatile float _colPosCorrect = 1f;
    private volatile float _colSlop = 0.40f;
    private volatile float _colMaxCorrect = 10f;
    private volatile float _colSpringK = 6.0f;
    private volatile float _colDamp = 0.03f;
    private volatile float _colFriction = 0.01f;

    private volatile int _minLinksPerNode = 1;
    private volatile int _maxLinksPerNode = 3;
    private volatile bool _enableExtraLink = true;
    private volatile float _extraLinkChance = 0.25f;

    // resources: icons (TERAZ: ścieżki plików, nie ms-appx)
    private string[] _iconUris = Array.Empty<string>(); // trzymamy nazewnictwo, ale to PATHy
    private CanvasBitmap[] _icons = Array.Empty<CanvasBitmap>();
    private bool _iconsDirty = true;

    // init-icons state
    private volatile bool _iconsLoading;
    private volatile bool _iconsLoadAttempted;
    private volatile bool _pendingIconReassign;

    // resources: background (light/dark) - PATHy
    private string _bgLightUri = string.Empty;
    private string _bgDarkUri = string.Empty;
    private CanvasBitmap? _bgLight;
    private CanvasBitmap? _bgDark;
    private bool _bgDirty = true;

    // theme cache
    private volatile bool _isDarkTheme;
    private bool _themeHooked;

    // sim state
    private Random _rng = new(1234);
    private int _nextId = 1;
    private float _spawnAcc = 0f;
    private bool _seeded;
    private float _time;

    // dt cache
    private float _dtLast = 1f / 60f;

    private const float KillX = -100f;
    private const float CollisionLeftPad = -300f;
    private const float CollisionRightPad = 200f;
    private const float CollisionTopPad = 8f;
    private const float CollisionBottomPad = 8f;

    // edge springs tuning
    private const float EdgeSpringK = 70.2f;
    private const float EdgeSpringDamp = 0.001f;
    private const float EdgeRestLen = 60f;
    private const float EdgeLenPerRadius = 1.6f;
    private const float EdgeMaxAccel = 300f;
    private const float EdgeMaxForce = 399f;
    private const float EdgeOnlyWhenVisiblePad = 80f;

    // magnetic bounds tuning
    private const float BoundMagRange = 60f;
    private const float BoundMagK = 48f;
    private const float BoundMagDamp = 0.01f;
    private const float BoundMaxAccel = 380f;

    private readonly List<Node> _nodes = new();
    private readonly List<Edge> _edges = new();
    private readonly Dictionary<int, int> _idToIndex = new();
    private readonly Dictionary<long, List<int>> _grid = new();

    // =====================================================================
    //  MODEL
    // =====================================================================

    private sealed class Node
    {
        public int Id;
        public Vector2 Pos;
        public Vector2 Vel;
        public float Radius;
        public byte Alpha;

        public float SpeedMul;
        public bool UseColorA;
        public int IconIndex;

        public float Phase;
        public float WobbleAmp;
        public float WobbleFreq;

        public float RotationRad; // 0..2π
    }

    private readonly struct Edge
    {
        public readonly int A;
        public readonly int B;
        public Edge(int a, int b) { A = a; B = b; }
    }

    public ForceDirectedAnimation()
    {
        InitializeComponent();

        Loaded += (_, _) =>
        {
            SyncSettingsCache();
            HookTheme();
            _seeded = false;
        };

        Unloaded += (_, _) => UnhookTheme();
    }

    private static void OnSettingsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        => ((ForceDirectedAnimation)d).SyncSettingsCache();

    // =====================================================================
    //  THEME
    // =====================================================================

    private void HookTheme()
    {
        if (_themeHooked) return;
        _themeHooked = true;

        ActualThemeChanged += OnActualThemeChanged;
        UpdateThemeCache();
    }

    private void UnhookTheme()
    {
        if (!_themeHooked) return;
        _themeHooked = false;

        ActualThemeChanged -= OnActualThemeChanged;
    }

    private void OnActualThemeChanged(FrameworkElement sender, object args)
        => UpdateThemeCache();

    private void UpdateThemeCache()
        => _isDarkTheme = (ActualTheme == ElementTheme.Dark);

    // =====================================================================
    //  SETTINGS -> CACHE
    // =====================================================================

    private void SyncSettingsCache()
    {
        _nodesOpacity = (bool)GetValue(NodesOpocityProperty);

        _topLeftY = (float)(double)GetValue(TopLeftYProperty);
        _topRightY = (float)(double)GetValue(TopRightYProperty);
        _bottomLeftY = (float)(double)GetValue(BottomLeftYProperty);
        _bottomRightY = (float)(double)GetValue(BottomRightYProperty);

        _invisibleBouncePad = (float)(double)GetValue(InvisibleBouncePadProperty);
        if (_invisibleBouncePad < 0) _invisibleBouncePad = 0;

        _bounceDamping = (float)(double)GetValue(BounceDampingProperty);
        _bounceDamping = Math.Clamp(_bounceDamping, 0.0f, 1.0f);

        _scrollSpeed = (float)(double)GetValue(ScrollSpeedProperty);
        _nodeSpawnRate = (float)(double)GetValue(NodeSpawnRateProperty);
        _maxNodes = (int)GetValue(MaxNodesProperty);

        _minR = (float)(double)GetValue(MinNodeRadiusProperty);
        _maxR = (float)(double)GetValue(MaxNodeRadiusProperty);
        if (_maxR < _minR) _maxR = _minR;

        _collisionPadding = (float)(double)GetValue(CollisionPaddingProperty);
        _collisionIterations = Math.Clamp((int)GetValue(CollisionIterationsProperty), 0, 8);

        _appearWidth = (float)(double)GetValue(AppearWidthProperty);
        if (_appearWidth < 1f) _appearWidth = 1f;

        _colorA = PackArgb((Color)GetValue(NodeColorAProperty));
        _colorB = PackArgb((Color)GetValue(NodeColorBProperty));

        double w = (double)GetValue(ColorAWeightProperty);
        if (double.IsNaN(w)) w = 0.85;
        _colorAWeight = (float)Math.Clamp(w, 0.0, 1.0);

        _iconMinDiameter = (float)(double)GetValue(IconMinDiameterProperty);
        if (_iconMinDiameter < 0) _iconMinDiameter = 0;

        _iconScale = (float)(double)GetValue(IconScaleProperty);
        _iconScale = Math.Clamp(_iconScale, 0.05f, 1.0f);

        _randomIconRotation = (bool)GetValue(RandomIconRotationProperty);

        _drawEdges = (bool)GetValue(DrawEdgesProperty);
        _fillNodes = (bool)GetValue(FillNodesProperty);

        _pulseIconWidth = (bool)GetValue(PulseIconWidthProperty);

        double amp = (double)GetValue(PulseIconWidthAmplitudeProperty);
        if (double.IsNaN(amp)) amp = 0.22;
        _pulseAmp = (float)Math.Clamp(amp, 0.0, 0.60);

        double freq = (double)GetValue(PulseIconWidthFrequencyProperty);
        if (double.IsNaN(freq)) freq = 6.0;
        _pulseFreq = (float)Math.Clamp(freq, 0.1, 20.0);

        _fireworkBurst = (bool)GetValue(FireworkIconBurstProperty);

        double off = (double)GetValue(FireworkOffDurationProperty);
        double ex = (double)GetValue(FireworkExpandDurationProperty);
        double hd = (double)GetValue(FireworkHoldDurationProperty);
        double fd = (double)GetValue(FireworkFadeOutDurationProperty);

        _fwOff = (float)Math.Clamp(double.IsNaN(off) ? 0.80 : off, 0.0, 10.0);
        _fwExpand = (float)Math.Clamp(double.IsNaN(ex) ? 0.10 : ex, 0.02, 3.0);
        _fwHold = (float)Math.Clamp(double.IsNaN(hd) ? 0.22 : hd, 0.0, 10.0);
        _fwFade = (float)Math.Clamp(double.IsNaN(fd) ? 0.18 : fd, 0.02, 5.0);

        double ms = (double)GetValue(FireworkMinScaleProperty);
        double ts = (double)GetValue(FireworkTargetScaleProperty);
        _fwMinScale = (float)Math.Clamp(double.IsNaN(ms) ? 0.06 : ms, 0.01, 1.0);
        _fwTargetScale = (float)Math.Clamp(double.IsNaN(ts) ? 1.00 : ts, 0.20, 10.0);

        double fa = (double)GetValue(FireworkFlareAmplitudeProperty);
        double ff = (double)GetValue(FireworkFlareFrequencyProperty);
        _fwFlareAmp = (float)Math.Clamp(double.IsNaN(fa) ? 0.65 : fa, 0.0, 2.5);
        _fwFlareFreq = (float)Math.Clamp(double.IsNaN(ff) ? 10.0 : ff, 0.1, 40.0);

        double pc = (double)GetValue(CollisionPositionCorrectionProperty);
        _colPosCorrect = (float)Math.Clamp(double.IsNaN(pc) ? 1.0 : pc, 0.0, 1.0);

        double sl = (double)GetValue(CollisionSlopPxProperty);
        _colSlop = (float)Math.Clamp(double.IsNaN(sl) ? 0.40 : sl, 0.0, 4.0);

        double mc = (double)GetValue(CollisionMaxCorrectionPxProperty);
        _colMaxCorrect = (float)Math.Clamp(double.IsNaN(mc) ? 10.0 : mc, 0.0, 50.0);

        double sk = (double)GetValue(CollisionSeparationSpringProperty);
        _colSpringK = (float)Math.Clamp(double.IsNaN(sk) ? 6.0 : sk, 0.0, 25.0);

        double cd = (double)GetValue(CollisionImpulseDampingProperty);
        _colDamp = (float)Math.Clamp(double.IsNaN(cd) ? 0.03 : cd, 0.0, 1.0);

        double fr = (double)GetValue(CollisionTangentFrictionProperty);
        _colFriction = (float)Math.Clamp(double.IsNaN(fr) ? 0.01 : fr, 0.0, 0.5);

        _minLinksPerNode = Math.Clamp((int)GetValue(MinLinksPerNodeProperty), 0, 20);
        _maxLinksPerNode = Math.Clamp((int)GetValue(MaxLinksPerNodeProperty), 0, 20);
        if (_maxLinksPerNode < _minLinksPerNode) _maxLinksPerNode = _minLinksPerNode;

        _enableExtraLink = (bool)GetValue(EnableExtraLinkProperty);

        double ch = (double)GetValue(ExtraLinkChanceProperty);
        _extraLinkChance = (float)Math.Clamp(double.IsNaN(ch) ? 0.25 : ch, 0.0, 1.0);

        // ====== PATHY (UNPACKAGED) ======
        _iconUris = SplitPaths((string)GetValue(IconUrisProperty) ?? string.Empty);
        _iconsDirty = true;

        _iconsLoadAttempted = false;
        _pendingIconReassign = false;

        _bgLightUri = NormalizePath((string)GetValue(BackgroundImageUriLightProperty) ?? string.Empty);
        _bgDarkUri = NormalizePath((string)GetValue(BackgroundImageUriDarkProperty) ?? string.Empty);

        var legacy = NormalizePath((string)GetValue(BackgroundImageUriProperty) ?? string.Empty);
        if (string.IsNullOrWhiteSpace(_bgLightUri) && !string.IsNullOrWhiteSpace(legacy))
            _bgLightUri = legacy;

        if (string.IsNullOrWhiteSpace(_bgDarkUri) && !string.IsNullOrWhiteSpace(_bgLightUri))
            _bgDarkUri = _bgLightUri;

        _bgDirty = true;
    }

    // =====================================================================
    //  RESOURCES
    // =====================================================================

    private void Animated_CreateResources(CanvasAnimatedControl sender, CanvasCreateResourcesEventArgs args)
    {
        if (!_iconsDirty && !_bgDirty) return;
        args.TrackAsyncAction(LoadResourcesAsync(sender).AsAsyncAction());
    }

    private async System.Threading.Tasks.Task LoadResourcesAsync(CanvasAnimatedControl sender)
    {
        if (_bgDirty)
        {
            _bgDirty = false;
            _bgLight = await TryLoadBitmapAsync(sender, _bgLightUri);
            _bgDark = await TryLoadBitmapAsync(sender, _bgDarkUri);
        }

        if (_iconsDirty)
        {
            _iconsDirty = false;

            _iconsLoading = true;
            try
            {
                var paths = _iconUris;
                var list = new List<CanvasBitmap>(paths.Length);

                for (int i = 0; i < paths.Length; i++)
                {
                    try
                    {
                        var p = paths[i];
                        if (!File.Exists(p)) continue;

                        await using var fs = new FileStream(p, FileMode.Open, FileAccess.Read, FileShare.Read);
                        list.Add(await CanvasBitmap.LoadAsync(sender.Device, fs.AsRandomAccessStream()));
                    }
                    catch { }
                }

                _icons = list.ToArray();

                // po doładowaniu ikon popraw iconIndex w już istniejących nodach
                _pendingIconReassign = true;
            }
            finally
            {
                _iconsLoading = false;
                _iconsLoadAttempted = true;
            }
        }
    }

    private static async System.Threading.Tasks.Task<CanvasBitmap?> TryLoadBitmapAsync(CanvasAnimatedControl sender, string path)
    {
        if (string.IsNullOrWhiteSpace(path)) return null;

        try
        {
            if (!File.Exists(path)) return null;

            await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
            return await CanvasBitmap.LoadAsync(sender.Device, fs.AsRandomAccessStream());
        }
        catch { return null; }
    }

    // =====================================================================
    //  HOT RELOAD ICONS
    // =====================================================================

    public async System.Threading.Tasks.Task ReloadIconsAsync(
        CanvasAnimatedControl canvas,
        IEnumerable<string> iconUris,
        bool reassignNodeIconIndices = true)
    {
        if (canvas == null) throw new ArgumentNullException(nameof(canvas));

        var paths = (iconUris ?? Array.Empty<string>())
            .Select(u => NormalizePath(u ?? string.Empty))
            .Where(u => !string.IsNullOrWhiteSpace(u))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToArray();

        var newIcons = new List<CanvasBitmap>(paths.Length);

        for (int i = 0; i < paths.Length; i++)
        {
            try
            {
                var p = paths[i];
                if (!File.Exists(p)) continue;

                await using var fs = new FileStream(p, FileMode.Open, FileAccess.Read, FileShare.Read);
                var bmp = await CanvasBitmap.LoadAsync(canvas.Device, fs.AsRandomAccessStream());
                newIcons.Add(bmp);
            }
            catch { }
        }

        var oldIcons = _icons;
        _iconUris = paths;
        _icons = newIcons.ToArray();
        _iconsDirty = false;

        _iconsLoading = false;
        _iconsLoadAttempted = true;
        _pendingIconReassign = reassignNodeIconIndices;

        if (oldIcons != null)
            for (int i = 0; i < oldIcons.Length; i++)
                oldIcons[i]?.Dispose();

        if (reassignNodeIconIndices)
            ReassignNodeIcons();

        if (!DispatcherQueue.TryEnqueue(() => canvas.Invalidate()))
            canvas.Invalidate();
    }

    // =====================================================================
    //  FULL RELOAD (tło + ikony + pełny reset symulacji)
    // =====================================================================

    public async System.Threading.Tasks.Task ReloadAllAsync(
        bool reloadBackground = true,
        bool reloadIcons = true,
        bool resetSimulation = true,
        int? randomSeed = null,
        bool reassignNodeIconIndices = true)
    {
        CanvasAnimatedControl canvas = Animated;
        if (canvas == null) throw new ArgumentNullException(nameof(canvas));

        SyncSettingsCache();
        UpdateThemeCache();

        if (reloadBackground)
        {
            _bgDirty = true;
            _bgLight?.Dispose(); _bgLight = null;
            _bgDark?.Dispose(); _bgDark = null;
        }

        if (reloadIcons)
        {
            _iconsDirty = true;
            if (_icons != null)
                for (int i = 0; i < _icons.Length; i++)
                    _icons[i]?.Dispose();
            _icons = Array.Empty<CanvasBitmap>();

            _iconsLoading = false;
            _iconsLoadAttempted = false;
            _pendingIconReassign = false;
        }

        if (resetSimulation)
            ResetSimulationState(randomSeed);

        await LoadResourcesAsync(canvas);

        if (!resetSimulation && reloadIcons && reassignNodeIconIndices)
            ReassignNodeIcons();

        if (!DispatcherQueue.TryEnqueue(() =>
        {
            try { canvas.ResetElapsedTime(); } catch { }
            canvas.Invalidate();
        }))
        {
            try { canvas.ResetElapsedTime(); } catch { }
            canvas.Invalidate();
        }
    }

    private void ResetSimulationState(int? randomSeed)
    {
        if (randomSeed.HasValue)
            _rng = new Random(randomSeed.Value);

        _time = 0f;
        _dtLast = 1f / 60f;
        _spawnAcc = 0f;
        _nextId = 1;
        _seeded = false;

        _grid.Clear();
        _nodes.Clear();
        _edges.Clear();
        _idToIndex.Clear();
    }

    private void ReassignNodeIcons()
    {
        int len = _icons?.Length ?? 0;

        for (int i = 0; i < _nodes.Count; i++)
        {
            var n = _nodes[i];

            if (len > 0 && (n.Radius * 2f) >= _iconMinDiameter)
                n.IconIndex = _rng.Next(len);
            else
                n.IconIndex = -1;

            if (_randomIconRotation && n.RotationRad == 0f)
                n.RotationRad = (float)_rng.NextDouble() * (MathF.PI * 2f);

            _nodes[i] = n;
        }
    }

    // =====================================================================
    //  UPDATE / DRAW
    // =====================================================================

    private void Animated_Update(ICanvasAnimatedControl sender, CanvasAnimatedUpdateEventArgs args)
    {
        float dt = (float)args.Timing.ElapsedTime.TotalSeconds;
        if (dt <= 0) return;
        if (dt > 0.05f) dt = 0.05f;

        _dtLast = dt;

        float w = (float)sender.Size.Width;
        float h = (float)sender.Size.Height;
        if (w <= 1 || h <= 1) return;

        _time += dt;

        if (_pendingIconReassign)
        {
            _pendingIconReassign = false;
            ReassignNodeIcons();
        }

        if (!_seeded)
        {
            if (_iconUris.Length > 0 && !_iconsLoadAttempted)
                return;

            int initial = Math.Min(_maxNodes, 40);
            Seed(sender, initial);
            _seeded = true;
        }

        UpdateMotion(dt, sender);
        UpdateSpawning(dt, sender);

        if (_collisionIterations > 0 && _nodes.Count > 1)
            ResolveOverlaps(sender);

        CullLeft();
    }

    private void Animated_Draw(ICanvasAnimatedControl sender, CanvasAnimatedDrawEventArgs args)
    {
        var ds = args.DrawingSession;

        ds.Clear(Colors.Transparent);
        ds.Blend = CanvasBlend.Copy;
        ds.Antialiasing = CanvasAntialiasing.Antialiased;

        float w = (float)sender.Size.Width;
        float h = (float)sender.Size.Height;

        var bg = _isDarkTheme ? _bgDark : _bgLight;
        if (bg != null)
            ds.DrawImage(bg, new Rect(0, 0, w, h));

        // ===== EDGES =====
        if (_drawEdges)
        {
            for (int i = 0; i < _edges.Count; i++)
            {
                var e = _edges[i];
                if (!TryGetNode(e.A, out var na)) continue;
                if (!TryGetNode(e.B, out var nb)) continue;

                byte a = (byte)Math.Min(na.Alpha, nb.Alpha);
                a = (byte)(a * 0.55f);
                ds.DrawLine(na.Pos, nb.Pos, Color.FromArgb(a, 160, 160, 160), 0.3f);
            }
        }

        var icons = _icons;

        // ===== NODES =====
        for (int i = 0; i < _nodes.Count; i++)
        {
            try
            {
                var n = _nodes[i];

                uint baseArgb = n.UseColorA ? _colorA : _colorB;

                byte a = n.Alpha;
                if (_isDarkTheme)
                    a = (byte)Math.Clamp(a - 140, 0, 255);

                if (_fillNodes)
                    ds.FillCircle(n.Pos, n.Radius, WithAlpha(a, baseArgb));

                if (n.IconIndex < 0 || n.IconIndex >= icons.Length) continue;
                if ((n.Radius * 2f) < _iconMinDiameter) continue;

                float baseD = n.Radius * 2f * _iconScale;

                // ===== Firework state =====
                bool fwVisible = true;
                float fwScale = 1f;
                float fwOpacityMul = 1f;
                if (_fireworkBurst)
                    GetFireworkState(_time, n.Phase, out fwVisible, out fwScale, out fwOpacityMul);

                if (!fwVisible)
                    continue;

                float d = baseD * fwScale;

                // ===== PULSE (only width) =====
                float wScale = 1f;
                if (_pulseIconWidth && _pulseAmp > 0f)
                {
                    float phase = n.Phase * 0.37f;
                    float s = MathF.Sin((_time * _pulseFreq + phase) * MathF.PI * 2f);
                    wScale = 1f + (s * _pulseAmp);
                    wScale = Math.Clamp(wScale, 0.65f, 1.35f);
                }

                float dw = d * wScale;
                float x = (n.Pos.X - dw * 0.5f);
                float y = (n.Pos.Y - d * 0.5f);

                var prevBlend = ds.Blend;
                var prevTransform = ds.Transform;

                ds.Blend = CanvasBlend.Add;
                if (!_nodesOpacity)
                    ds.Blend = CanvasBlend.SourceOver;

                float opacity = (_nodesOpacity) ? MathF.Min(0.9f, n.Alpha / 128f) : MathF.Min(1f, n.Alpha / 256f);
                if (_isDarkTheme)
                    opacity = MathF.Min(0.4f, n.Alpha / 32f);

                opacity *= fwOpacityMul;
                opacity = Math.Clamp(opacity, 0f, 1f);

                if (_fireworkBurst)
                {
                    ds.Blend = CanvasBlend.SourceOver;
                    opacity = Math.Clamp(opacity * (Math.Max(n.Radius / 42f, 0.6f)), 0, 0.90f);
                }

                // ===== LOSOWA ROTACJA =====
                if (_randomIconRotation)
                    ds.Transform = Matrix3x2.CreateRotation(n.RotationRad, n.Pos) * prevTransform;

                ds.DrawImage(
                    icons[n.IconIndex],
                    new Rect(x, y, dw, d),
                    icons[n.IconIndex].Bounds,
                    opacity,
                    CanvasImageInterpolation.HighQualityCubic);

                ds.Transform = prevTransform;
                ds.Blend = prevBlend;
            }
            catch { }
        }
    }

    // =====================================================================
    //  SIM
    // =====================================================================

    private void Seed(ICanvasAnimatedControl sender, int initialCount)
    {
        _nodes.Clear();
        _edges.Clear();
        _idToIndex.Clear();
        _spawnAcc = 0;
        _nextId = 1;

        for (int i = 0; i < initialCount; i++)
            SpawnNode(sender, spawnOutsideRight: false);

        Reindex();

        for (int i = 0; i < _nodes.Count; i++)
            ConnectNewNode(_nodes[i].Id);

        CompactEdges();

        if (_collisionIterations > 0 && _nodes.Count > 1)
            ResolveOverlaps(sender);
    }

    private void UpdateMotion(float dt, ICanvasAnimatedControl sender)
    {
        float w = (float)sender.Size.Width;
        float h = (float)sender.Size.Height;

        ApplyEdgeSprings(dt, w);

        float baseDx = _scrollSpeed * dt;
        float fadeIn = _appearWidth;

        for (int i = 0; i < _nodes.Count; i++)
        {
            var n = _nodes[i];

            n.Pos.X -= baseDx * n.SpeedMul;
            n.Pos.X += n.Vel.X * dt;

            float wob = MathF.Sin((n.Phase + _time * n.WobbleFreq) * MathF.PI * 2f) * n.WobbleAmp;
            n.Vel.Y += wob * dt;
            n.Vel.Y = Math.Clamp(n.Vel.Y, -30f, 30f);

            n.Pos.Y += n.Vel.Y * dt;

            ApplyMagneticBounds(ref n, dt, w, h);

            float a = 255f;
            if (n.Pos.X > w - fadeIn)
                a = 255f * (1f - (n.Pos.X - (w - fadeIn)) / fadeIn);

            n.Alpha = (byte)Math.Clamp(a, 0, 255);
            _nodes[i] = n;
        }
    }

    private void UpdateSpawning(float dt, ICanvasAnimatedControl sender)
    {
        float rate = _nodeSpawnRate;
        if (rate <= 0) return;

        _spawnAcc += dt * rate;
        int toSpawn = (int)_spawnAcc;
        if (toSpawn <= 0) return;

        _spawnAcc -= toSpawn;

        for (int i = 0; i < toSpawn; i++)
        {
            if (_nodes.Count >= _maxNodes) break;

            int id = SpawnNode(sender, spawnOutsideRight: true);
            _idToIndex[id] = _nodes.Count - 1;
            ConnectNewNode(id);
        }

        CompactEdges();
    }

    private int SpawnNode(ICanvasAnimatedControl sender, bool spawnOutsideRight)
    {
        float w = (float)sender.Size.Width;
        float h = (float)sender.Size.Height;

        float x = spawnOutsideRight ? w + 20f + (float)_rng.NextDouble() * 80f : (float)_rng.NextDouble() * w;
        float r = _minR + (float)_rng.NextDouble() * (_maxR - _minR);

        GetBoundsAtX(x, w, h, out float yTop0, out float yBottom0);
        float yTop = yTop0 + r + 2f;
        float yBottom = yBottom0 - r - 2f;
        if (yTop > yBottom) (yTop, yBottom) = (yBottom, yTop);

        float yMid = (yTop + yBottom) * 0.5f;
        float half = MathF.Max(1f, (yBottom - yTop) * 0.5f);
        float y = yMid + NextGaussian() * (half * 0.35f);
        y = Math.Clamp(y, yTop, yBottom);

        if (_rng.NextDouble() < 0.10)
        {
            float yo = yMid + NextGaussian() * (half * 0.75f);
            y = Math.Clamp(yo, yTop, yBottom);
        }

        float speedMul = 0.92f + (float)_rng.NextDouble() * 0.5f;

        float vx = ((float)_rng.NextDouble() - 0.45f) * 10f;
        float vy = NextGaussian() * 8f;
        vy = Math.Clamp(vy, -18f, 18f);

        bool useA = _rng.NextDouble() < _colorAWeight;

        int iconIndex = -1;
        var icons = _icons;
        if (icons.Length > 0 && (r * 2f) >= _iconMinDiameter)
            iconIndex = _rng.Next(icons.Length);

        int id = _nextId++;

        _nodes.Add(new Node
        {
            Id = id,
            Pos = new Vector2(x, y),
            Vel = new Vector2(vx, vy),
            Radius = r,
            Alpha = spawnOutsideRight ? (byte)0 : (byte)255,
            SpeedMul = speedMul,
            UseColorA = useA,
            IconIndex = iconIndex,

            Phase = (float)_rng.NextDouble() * 10f,
            WobbleAmp = 6f + (float)_rng.NextDouble() * 10f,
            WobbleFreq = 0.10f + (float)_rng.NextDouble() * 0.25f,

            RotationRad = (float)_rng.NextDouble() * (MathF.PI * 2f)
        });

        return id;
    }

    private void CullLeft()
    {
        bool removedAny = false;

        for (int i = _nodes.Count - 1; i >= 0; i--)
        {
            if (_nodes[i].Pos.X < KillX)
            {
                int id = _nodes[i].Id;
                _nodes.RemoveAt(i);

                for (int e = _edges.Count - 1; e >= 0; e--)
                    if (_edges[e].A == id || _edges[e].B == id)
                        _edges.RemoveAt(e);

                removedAny = true;
            }
        }

        if (removedAny)
        {
            Reindex();
            CompactEdges();
        }
    }

    // =====================================================================
    //  GRAPH CONNECTIVITY
    // =====================================================================

    private void ConnectNewNode(int newId)
    {
        if (!TryGetNode(newId, out var n)) return;
        if (_nodes.Count < 2) return;

        int links;
        if (_maxLinksPerNode <= _minLinksPerNode)
            links = _minLinksPerNode;
        else
            links = _minLinksPerNode + _rng.Next(_maxLinksPerNode - _minLinksPerNode + 1);

        Span<int> bestIdx = stackalloc int[8];
        Span<float> bestDist = stackalloc float[8];
        int bestCount = 0;

        for (int i = 0; i < _nodes.Count; i++)
        {
            var other = _nodes[i];
            if (other.Id == newId) continue;

            float d = Vector2.DistanceSquared(n.Pos, other.Pos);
            InsertTopK(i, d, bestIdx, bestDist, ref bestCount, 8);
        }

        int take = Math.Min(links, bestCount);
        for (int k = 0; k < take; k++)
            AddEdgeUnique(newId, _nodes[bestIdx[k]].Id);

        if (_enableExtraLink && _nodes.Count > 3 && _extraLinkChance > 0f && _rng.NextDouble() < _extraLinkChance)
        {
            var rnd = _nodes[_rng.Next(_nodes.Count)];
            if (rnd.Id != newId) AddEdgeUnique(newId, rnd.Id);
        }
    }

    private void AddEdgeUnique(int a, int b)
    {
        if (a == b) return;
        if (a > b) (a, b) = (b, a);

        for (int i = 0; i < _edges.Count; i++)
        {
            int ea = _edges[i].A, eb = _edges[i].B;
            if (ea > eb) (ea, eb) = (eb, ea);
            if (ea == a && eb == b) return;
        }

        _edges.Add(new Edge(a, b));
    }

    private void CompactEdges()
    {
        int maxEdges = Math.Max(30, _nodes.Count * 5);
        if (_edges.Count <= maxEdges) return;
        _edges.RemoveRange(0, _edges.Count - maxEdges);
    }

    // =====================================================================
    //  EDGE SPRINGS
    // =====================================================================

    private void ApplyEdgeSprings(float dt, float w)
    {
        if (_edges.Count == 0 || _nodes.Count < 2) return;

        const float MinMass = 16f;
        const float MaxMass = 900f;

        for (int e = 0; e < _edges.Count; e++)
        {
            var ed = _edges[e];

            if (!_idToIndex.TryGetValue(ed.A, out int ia)) continue;
            if (!_idToIndex.TryGetValue(ed.B, out int ib)) continue;
            if ((uint)ia >= (uint)_nodes.Count || (uint)ib >= (uint)_nodes.Count) continue;
            if (ia == ib) continue;

            var a = _nodes[ia];
            var b = _nodes[ib];

            if (a.Pos.X > w + EdgeOnlyWhenVisiblePad && b.Pos.X > w + EdgeOnlyWhenVisiblePad)
                continue;

            Vector2 d = b.Pos - a.Pos;
            float dist2 = d.LengthSquared();
            if (dist2 < 1e-6f) continue;

            float dist = MathF.Sqrt(dist2);
            Vector2 n = d / dist;

            float rest = EdgeRestLen + (a.Radius + b.Radius) * EdgeLenPerRadius;
            float x = dist - rest;
            float relVelN = Vector2.Dot(b.Vel - a.Vel, n);

            float f = (x * EdgeSpringK) + (relVelN * EdgeSpringDamp);
            f = Math.Clamp(f, -EdgeMaxForce, EdgeMaxForce);

            float ma = Math.Clamp(a.Radius * a.Radius, MinMass, MaxMass);
            float mb = Math.Clamp(b.Radius * b.Radius, MinMass, MaxMass);

            float aa = Math.Clamp(f / ma, -EdgeMaxAccel, EdgeMaxAccel);
            float ab = Math.Clamp(f / mb, -EdgeMaxAccel, EdgeMaxAccel);

            a.Vel += n * (aa * dt);
            b.Vel -= n * (ab * dt);

            a.Vel.X = Math.Clamp(a.Vel.X, -35f, 35f);
            a.Vel.Y = Math.Clamp(a.Vel.Y, -35f, 35f);
            b.Vel.X = Math.Clamp(b.Vel.X, -35f, 35f);
            b.Vel.Y = Math.Clamp(b.Vel.Y, -35f, 35f);

            _nodes[ia] = a;
            _nodes[ib] = b;
        }
    }

    // =====================================================================
    //  MAGNETIC BOUNDS
    // =====================================================================

    private void ApplyMagneticBounds(ref Node n, float dt, float w, float h)
    {
        GetBoundsAtX(n.Pos.X, w, h, out float yTop0, out float yBottom0);

        float yTop = yTop0 - _invisibleBouncePad + n.Radius;
        float yBottom = yBottom0 + _invisibleBouncePad - n.Radius;
        if (yTop > yBottom) (yTop, yBottom) = (yBottom, yTop);

        float range = Math.Max(6f, BoundMagRange);

        float dTop = (n.Pos.Y - yTop);
        float dBot = (yBottom - n.Pos.Y);

        float ay = 0f;

        if (dTop < range)
        {
            float t = 1f - Math.Clamp(dTop / range, 0f, 1f);
            float s = t * t;
            ay += BoundMagK * s;
            n.Vel.Y *= (1f - (BoundMagDamp * s * dt));
        }

        if (dBot < range)
        {
            float t = 1f - Math.Clamp(dBot / range, 0f, 1f);
            float s = t * t;
            ay -= BoundMagK * s;
            n.Vel.Y *= (1f - (BoundMagDamp * s * dt));
        }

        if (dTop < 0f)
        {
            float s = Math.Clamp((-dTop) / 8f, 0f, 4f);
            ay += BoundMagK * (1.5f + s);
        }
        else if (dBot < 0f)
        {
            float s = Math.Clamp((-dBot) / 8f, 0f, 4f);
            ay -= BoundMagK * (1.5f + s);
        }

        ay = Math.Clamp(ay, -BoundMaxAccel, BoundMaxAccel);

        n.Vel.Y += ay * dt;
        n.Vel.Y = Math.Clamp(n.Vel.Y, -45f, 45f);
        n.Pos.Y = Math.Clamp(n.Pos.Y, yTop, yBottom);
    }

    // =====================================================================
    //  OVERLAPS (SMOOTH)
    // =====================================================================

    private void ResolveOverlaps(ICanvasAnimatedControl sender)
    {
        float w = (float)sender.Size.Width;
        float h = (float)sender.Size.Height;

        float cellSize = Math.Max(8f, _maxR * 2f + _collisionPadding * 2f);
        int iterations = _collisionIterations;

        float dt = Math.Max(1e-4f, _dtLast);
        float invDt = 1f / dt;

        for (int it = 0; it < iterations; it++)
        {
            BuildGrid(cellSize);

            for (int i = 0; i < _nodes.Count; i++)
            {
                var ni = _nodes[i];
                var ci = CellOf(ni.Pos, cellSize);

                for (int oy = -1; oy <= 1; oy++)
                    for (int ox = -1; ox <= 1; ox++)
                    {
                        long key = Pack(ci.cx + ox, ci.cy + oy);
                        if (!_grid.TryGetValue(key, out var list)) continue;

                        for (int k = 0; k < list.Count; k++)
                        {
                            int j = list[k];
                            if (j <= i) continue;

                            var nj = _nodes[j];

                            Vector2 d = nj.Pos - ni.Pos;
                            float dist2 = d.LengthSquared();

                            float minDist = ni.Radius + nj.Radius + _collisionPadding;
                            float minDist2 = minDist * minDist;
                            if (dist2 >= minDist2) continue;

                            float dist;
                            Vector2 n;
                            if (dist2 < 1e-6f)
                            {
                                float a = (ni.Id * 12.9898f + nj.Id * 78.233f) * 0.0174533f;
                                n = new Vector2(MathF.Cos(a), MathF.Sin(a));
                                dist = 0.001f;
                            }
                            else
                            {
                                dist = MathF.Sqrt(dist2);
                                n = d / dist;
                            }

                            float penetration = (minDist - dist);
                            penetration = MathF.Max(0f, penetration - _colSlop);
                            if (penetration <= 0) continue;

                            float corr = penetration * 0.5f * _colPosCorrect;
                            corr = MathF.Min(corr, _colMaxCorrect);

                            Vector2 posDelta = n * corr;
                            ni.Pos -= posDelta;
                            nj.Pos += posDelta;

                            Vector2 rv = nj.Vel - ni.Vel;
                            float relN = Vector2.Dot(rv, n);
                            bool approaching = relN < 0f;

                            float targetSepVel = penetration * _colSpringK * invDt;

                            float impulseN = approaching
                                ? (-relN + targetSepVel) * _colDamp
                                : (targetSepVel * 0.25f);

                            Vector2 imp = n * impulseN * 0.5f;
                            ni.Vel -= imp;
                            nj.Vel += imp;

                            Vector2 t = rv - n * Vector2.Dot(rv, n);
                            float tLen = t.Length();
                            if (tLen > 1e-4f)
                            {
                                Vector2 tn = t / tLen;
                                float jt = tLen * _colFriction;
                                Vector2 ft = tn * (jt * 0.5f);
                                ni.Vel += ft;
                                nj.Vel -= ft;
                            }

                            ni.Vel.Y = Math.Clamp(ni.Vel.Y, -35f, 35f);
                            nj.Vel.Y = Math.Clamp(nj.Vel.Y, -35f, 35f);
                            ni.Vel.X = Math.Clamp(ni.Vel.X, -25f, 25f);
                            nj.Vel.X = Math.Clamp(nj.Vel.X, -25f, 25f);

                            ni.Pos.X = Math.Clamp(ni.Pos.X, CollisionLeftPad, w + CollisionRightPad);
                            nj.Pos.X = Math.Clamp(nj.Pos.X, CollisionLeftPad, w + CollisionRightPad);

                            ClampYToBounds(ref ni, w, h, CollisionTopPad, CollisionBottomPad);
                            ClampYToBounds(ref nj, w, h, CollisionTopPad, CollisionBottomPad);

                            _nodes[i] = ni;
                            _nodes[j] = nj;
                        }
                    }
            }
        }
    }

    private void ClampYToBounds(ref Node n, float w, float h, float topPad, float bottomPad)
    {
        GetBoundsAtX(n.Pos.X, w, h, out float yTop0, out float yBottom0);

        float yTop = yTop0 - _invisibleBouncePad + topPad + n.Radius;
        float yBottom = yBottom0 + _invisibleBouncePad - bottomPad - n.Radius;

        if (yTop > yBottom) (yTop, yBottom) = (yBottom, yTop);

        n.Pos.Y = Math.Clamp(n.Pos.Y, yTop, yBottom);
    }

    private void BuildGrid(float cellSize)
    {
        _grid.Clear();
        for (int i = 0; i < _nodes.Count; i++)
        {
            var c = CellOf(_nodes[i].Pos, cellSize);
            long key = Pack(c.cx, c.cy);
            if (!_grid.TryGetValue(key, out var list))
                _grid[key] = list = new List<int>(8);
            list.Add(i);
        }
    }

    private static (int cx, int cy) CellOf(Vector2 p, float cellSize)
        => ((int)MathF.Floor(p.X / cellSize), (int)MathF.Floor(p.Y / cellSize));

    private static long Pack(int x, int y) => ((long)x << 32) ^ (uint)y;

    // =====================================================================
    //  BOUNDS (4 rogi -> yTop/yBottom)
    // =====================================================================

    private void GetBoundsAtX(float x, float w, float h, out float yTop, out float yBottom)
    {
        float t = (w <= 1f) ? 0f : (x / w);

        float topFromBottom = Lerp(_topLeftY, _topRightY, t);
        float bottomFromBottom = Lerp(_bottomLeftY, _bottomRightY, t);

        yTop = h - topFromBottom;
        yBottom = h - bottomFromBottom;

        if (yTop > yBottom) (yTop, yBottom) = (yBottom, yTop);
    }

    // =====================================================================
    //  LOOKUP
    // =====================================================================

    private void Reindex()
    {
        _idToIndex.Clear();
        for (int i = 0; i < _nodes.Count; i++)
            _idToIndex[_nodes[i].Id] = i;
    }

    private bool TryGetNode(int id, out Node node)
    {
        if (_idToIndex.TryGetValue(id, out int idx) && (uint)idx < (uint)_nodes.Count)
        {
            node = _nodes[idx];
            return true;
        }
        node = default!;
        return false;
    }

    // =====================================================================
    //  UTILS
    // =====================================================================

    private static float Lerp(float a, float b, float t) => a + (b - a) * t;

    private static void InsertTopK(int idx, float dist, Span<int> bestIdx, Span<float> bestDist, ref int bestCount, int k)
    {
        int n = bestCount;
        int pos = n;

        for (int i = 0; i < n; i++)
            if (dist < bestDist[i]) { pos = i; break; }

        if (pos >= k) return;

        int newCount = Math.Min(k, n + 1);

        for (int i = newCount - 1; i > pos; i--)
        {
            bestIdx[i] = bestIdx[i - 1];
            bestDist[i] = bestDist[i - 1];
        }

        bestIdx[pos] = idx;
        bestDist[pos] = dist;
        bestCount = newCount;
    }

    private float NextGaussian()
    {
        float u1 = 1f - (float)_rng.NextDouble();
        float u2 = 1f - (float)_rng.NextDouble();
        return MathF.Sqrt(-2f * MathF.Log(u1)) * MathF.Cos(2f * MathF.PI * u2);
    }

    // =====================================================================
    //  FIREWORK HELPERS
    // =====================================================================

    private static float EaseOutCubic(float t)
    {
        t = Math.Clamp(t, 0f, 1f);
        float u = 1f - t;
        return 1f - (u * u * u);
    }

    private static float EaseInCubic(float t)
    {
        t = Math.Clamp(t, 0f, 1f);
        return t * t * t;
    }

    // visible? + scaleMul + opacityMul
    private void GetFireworkState(float time, float phase, out bool visible, out float scaleMul, out float opacityMul)
    {
        float cycle = _fwOff + _fwExpand + _fwHold + _fwFade;
        if (cycle <= 1e-4f)
        {
            visible = true;
            scaleMul = _fwTargetScale;
            opacityMul = 1f;
            return;
        }

        float t0 = (time + phase * 0.91f) % cycle;
        if (t0 < 0) t0 += cycle;

        if (t0 < _fwOff)
        {
            visible = false;
            scaleMul = _fwMinScale;
            opacityMul = 0f;
            return;
        }
        t0 -= _fwOff;

        if (t0 < _fwExpand)
        {
            float t = EaseOutCubic(t0 / Math.Max(1e-4f, _fwExpand));
            visible = true;
            scaleMul = _fwMinScale + (_fwTargetScale - _fwMinScale) * t;
            opacityMul = 1f;
            return;
        }
        t0 -= _fwExpand;

        if (t0 < _fwHold)
        {
            visible = true;
            scaleMul = _fwTargetScale;

            float s = 0.5f + 0.5f * MathF.Sin((time * _fwFlareFreq + phase) * MathF.PI * 2f);
            float env = 1f - MathF.Abs((t0 / Math.Max(1e-4f, _fwHold)) * 2f - 1f);
            env = EaseOutCubic(env);

            opacityMul = 1f + (_fwFlareAmp * s * env);
            return;
        }
        t0 -= _fwHold;

        {
            float t = EaseInCubic(t0 / Math.Max(1e-4f, _fwFade));
            visible = true;
            scaleMul = _fwTargetScale;
            opacityMul = 1f - t;
            return;
        }
    }

    // =====================================================================
    //  COLOR + PATH HELPERS
    // =====================================================================

    private static uint PackArgb(Color c)
        => ((uint)c.A << 24) | ((uint)c.R << 16) | ((uint)c.G << 8) | c.B;

    private static Color WithAlpha(byte a, uint argb)
        => Color.FromArgb(a, (byte)((argb >> 16) & 0xFF), (byte)((argb >> 8) & 0xFF), (byte)(argb & 0xFF));

    /// <summary>
    /// UNPACKAGED: tylko ścieżki plików. Jeśli wejście jest względne -> względem folderu EXE.
    /// ms-appx:// jest zabronione.
    /// </summary>
    private static string NormalizePath(string s)
    {
        s = (s ?? string.Empty).Trim().Trim('"');
        if (string.IsNullOrWhiteSpace(s)) return string.Empty;

        if (s.StartsWith("ms-appx:", StringComparison.OrdinalIgnoreCase))
            throw new InvalidOperationException("ms-appx:// jest zabronione (projekt unpackaged). Użyj ścieżki pliku.");

        s = s.Replace('/', Path.DirectorySeparatorChar);

        if (Path.IsPathRooted(s)) return s;

        return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, s));
    }

    private static string[] SplitPaths(string s)
    {
        if (string.IsNullOrWhiteSpace(s)) return Array.Empty<string>();

        var parts = s.Split(new[] { ';', ',', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries);

        var list = new List<string>(parts.Length);
        for (int i = 0; i < parts.Length; i++)
        {
            var p = NormalizePath(parts[i]);
            if (!string.IsNullOrWhiteSpace(p))
                list.Add(p);
        }

        return list.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
    }
}