#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Input;

using AutoPropertyChanged;

using System.Diagnostics;

namespace JustTagAvalonia
{
    public class DialListBox<TItem> : UserControl, INotifyPropertyChanged
    {
        public IEnumerable<TItem> Items
        {
            get => _items;
            set
            {
                _items = value.ToArray();
                RaiseChanged("Items");
                RaiseChanged("ItemsCount");
            }
        }
        public int ItemsCount => _items.Length;

        [NotifyChanged] public int SelectedIndex { get; set; }
        [NotifyChanged] public double ScrollIndex { get; set; }    // How far through the list the user has scrolled.
                                                                   // Corresponds to the index of the item at the top of the screen.
                                                                   // EG:
                                                                   // At value 0, Items[0] is at the top of the screen.
                                                                   // At value 1, Items[1] is at the top of the screen.
                                                                   // At value 1.5, the top of the screen is halfway between items 1 and 2
                                                                   // Etc.

        public new event PropertyChangedEventHandler PropertyChanged;

        private TItem[] _items = new TItem[] { };

        private StackPanel itemsPanel;

        private Func<Control> templateControlFactory;
        private Action<TItem, Control> templateControlSetter;

        
        private List<ItemContainer> itemContainerPool = new List<ItemContainer>();
        private class ItemContainer : UserControl { }

        private bool isDragging = false;
        private Point prevDragPos;
        private Stopwatch dragTimer = new Stopwatch();

        public DialListBox()
        {
            itemsPanel = new StackPanel()
            {
                Orientation = Orientation.Vertical,
                HorizontalAlignment = HorizontalAlignment.Left,
                VerticalAlignment = VerticalAlignment.Top
            };

            this.Content = itemsPanel;

            PointerWheelChanged += ScrollWheelChanged;
            PointerPressed += DialListBox_PointerPressed;
            PointerMoved += DialListBox_PointerMoved;
            PointerReleased += DialListBox_PointerReleased;

            // Default template: Just textblocks with the item's ToString()
            templateControlFactory = () => new TextBlock();
            templateControlSetter = (item, control) =>
            {
                ((TextBlock)control).Text = item?.ToString() ?? "";
            };

            // HACK: Update some things when properties change
            PropertyChanged += (s, e) =>
            {
                switch (e.PropertyName)
                {
                    case "Items":
                        UpdateItemScroll();
                        UpdateSelectedItemHighlight();
                        break;

                    case "ScrollIndex":
                        UpdateItemScroll();
                        UpdateSelectedItemHighlight();
                        break;

                    case "SelectedIndex":
                        UpdateSelectedItemHighlight();
                        break;
                }
            };
        }


        // Helpers

        private void RaiseChanged(string propertyName)
        {
            PropertyChanged?.Invoke
            (
                this,
                new PropertyChangedEventArgs(propertyName)
            );
        }

        /// <summary>
        ///     Finds the index of the item that corresponds to a given control
        ///     from the container pool.
        /// </summary>
        /// <param name="poolIndex"></param>
        /// <returns></returns>
        private int GetItemIndex(int poolIndex) => (int)Math.Floor(ScrollIndex) + poolIndex;

        /// <summary>
        ///     Gets the index of the item that the mouse is over.
        ///     Returns null if it's not over anything.
        /// </summary>
        /// <param name="point"></param>
        /// <returns></returns>
        private int? GetItemIndexUnderPoint(Point point)
        {
            double totalHeight = 0;

            // We need to shift the y-value to account for the fact that
            // the margin shifts when partially scrolled between two items.
            double shiftedY = point.Y - itemsPanel.Margin.Top;

            for (int poolIndex = 0; poolIndex < itemContainerPool.Count; poolIndex++)
            {
                totalHeight += itemContainerPool[poolIndex].Bounds.Height;

                if (totalHeight >= shiftedY)
                    return GetItemIndex(poolIndex);
            }

            return null;
        }

        private void UpdateItemScroll()
        {
            // Hide all the existing controls.  We will
            // re-enable only the ones that are being used.
            foreach (ItemContainer c in itemContainerPool)
            {
                c.IsVisible = false;
            }

            // HACK: Avoid a null reference exception if
            // the items list is null.
            if (Items == null)
                return;

            // Show(or create) controls until we go past the boundaries of the list
            double maxHeight = this.Bounds.Height;

            int poolIndex = 0;
            double totalHeight = 0;
            
            while (true)
            {
                int itemIndex = GetItemIndex(poolIndex);

                // Bail out when done
                if (totalHeight > maxHeight || itemIndex >= ItemsCount)
                    break;

                // Create (or reuse) a control
                ItemContainer container;
                if (poolIndex >= itemContainerPool.Count)
                {
                    container = new ItemContainer();
                    container.Content = templateControlFactory();

                    itemContainerPool.Add(container);
                    itemsPanel.Children.Add(container);
                }
                container = itemContainerPool[poolIndex];
                container.IsVisible = true;

                // Apply the template to the control, with the current item
                // as the parameter.
                TItem item = Items.ElementAt(itemIndex);
                templateControlSetter(item, (Control)container.Content);

                // Move on to the next one
                poolIndex++;
                totalHeight += container.Bounds.Height;
            }

            // Shift the render margin up/down to account for "partially" scrolling between two items.
            // TODO: Use a render transform instead
            double controlHeight = itemContainerPool[0].Bounds.Height;
            double residual = ScrollIndex - GetItemIndex(0);

            itemsPanel.Margin = new Thickness(0, -residual * controlHeight);
        }

        private void UpdateSelectedItemHighlight()
        {
            for (int poolIndex = 0; poolIndex < itemContainerPool.Count; poolIndex++)
            {
                ItemContainer container = itemContainerPool[poolIndex];
                int itemIndex = GetItemIndex(poolIndex);

                container.Background = (itemIndex == SelectedIndex)
                    ? Brushes.SeaGreen
                    : Brushes.Transparent;
            }
        }

        // Event handlers

        private void ScrollWheelChanged(object? sender, PointerWheelEventArgs e)
        {
            // Scroll up/down with the wheel
            double newScrollIndex = ScrollIndex + e.Delta.Y;

            if (newScrollIndex < 0)
                newScrollIndex = 0;
            if (newScrollIndex >= ItemsCount)
                newScrollIndex = ItemsCount - 1;

            ScrollIndex = newScrollIndex;
        }

        private void DialListBox_PointerPressed(object? sender, PointerPressedEventArgs e)
        {
            isDragging = true;
            e.Device.Capture(this);
            prevDragPos = e.GetPosition(this);
            dragTimer.Restart();
        }

        private void DialListBox_PointerMoved(object? sender, PointerEventArgs e)
        {
            if (!isDragging)
                return;

            // HACK: Avoid index out of bounds error by not scrolling if there aren't
            // any items.
            if (ItemsCount < 1)
                return;

            // Calculate the delta Y and update prevDragPos
            Point currDragPos = e.GetPosition(this);
            double delta = currDragPos.Y - prevDragPos.Y;
            prevDragPos = currDragPos;

            // Update the scroll index
            double newScrollIndex = ScrollIndex - delta / itemContainerPool[0].Bounds.Height;

            if (newScrollIndex < 0)
                newScrollIndex = 0;
            if (newScrollIndex >= ItemsCount)
                newScrollIndex = ItemsCount - 1;

            ScrollIndex = newScrollIndex;
        }

        private void DialListBox_PointerReleased(object? sender, PointerReleasedEventArgs e)
        {
            if (!isDragging)
                return;

            // Stop dragging
            dragTimer.Stop();
            isDragging = false;
            e.Device.Capture(null);

            // If we weren't dragging very long, select the item under the cursor.
            if (dragTimer.ElapsedMilliseconds <= 250)
            {
                int? itemIndex = GetItemIndexUnderPoint(e.GetPosition(this));
                if (itemIndex != null)
                    SelectedIndex = itemIndex.Value;
            }
        }

        /// <summary>
        /// 
        /// </summary>
        /// <typeparam name="TControl"></typeparam>
        /// <param name="factory">
        ///     A factory function that creates a new instance of the control that should represent
        ///     an item.
        /// </param>
        /// <param name="setter">
        ///     A function that modifies a control(created by factory) to display a given item.
        /// </param>
        /// <returns></returns>
        public DialListBox<TItem> SetItemTemplate<TControl>(Func<TControl> factory, Action<TItem, TControl> setter)
            where TControl : Control
        {
            templateControlFactory = factory;
            templateControlSetter = (item, control) => setter(item, (TControl)control);
            UpdateItemScroll();

            return this;
        }
    }

}