@namespace Lumeo
@implements IDisposable

@{
    var focusedItemId = _focusedIndex >= 0 && _focusedIndex < _items.Count ? _items[_focusedIndex].Id : null;
    var focusedItemValue = _focusedIndex >= 0 && _focusedIndex < _items.Count ? _items[_focusedIndex].Value : null;
    var context = new ComboboxContext(Value, Values, SearchText, Open, Multiple, IsSearchLoading, Creatable,
        EventCallback.Factory.Create<string>(this, OnValueSelected),
        EventCallback.Factory.Create<bool>(this, SetOpen),
        EventCallback.Factory.Create<string>(this, OnSearchChanged),
        EventCallback.Factory.Create<string>(this, OnCreateItem),
        _wrapperId, _contentId, _focusedIndex, focusedItemId, focusedItemValue,
        EventCallback.Factory.Create<int>(this, SetFocusedIndex),
        RegisterItem, UnregisterItem, _items.Count, EffectiveInvalid, Required,
        Items, ItemValue, ItemText, ItemGroup, ItemDisabled, ItemTemplate, Virtualize, ItemSize);
}

@if (Label is not null && !InsideFormField)
{
    <label class="block text-sm font-medium mb-1.5">
        @Label
        @if (Required)
        {
            <span class="text-destructive ml-0.5">*</span>
        }
    </label>
}

<CascadingValue Value="context" IsFixed="false">
    <div id="@_wrapperId" class="relative" @attributes="AdditionalAttributes">
        @ChildContent
    </div>
</CascadingValue>

@if (!InsideFormField)
{
    @if (EffectiveInvalid && !string.IsNullOrEmpty(ErrorText))
    {
        <p class="mt-1 text-xs text-destructive">@ErrorText</p>
    }
    else if (!string.IsNullOrEmpty(HelperText))
    {
        <p class="mt-1 text-xs text-muted-foreground">@HelperText</p>
    }
}

@code {
    [CascadingParameter] private FormField.FormFieldContext? FormField { get; set; }

    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public string? Value { get; set; }
    [Parameter] public EventCallback<string> ValueChanged { get; set; }
    /// <summary>
    /// Popover open state. Bind via <c>@bind-Open</c> when the Combobox lives inside
    /// a parent that re-renders heavily (e.g. an <c>EditForm</c> with active
    /// validation) — otherwise a parameter cascade can close the popover mid-click.
    /// In the bound (controlled) mode the parent owns state and the popover survives
    /// re-renders. When unbound, the component still functions but is vulnerable to
    /// the cascade-close race; binding is the recommended pattern.
    /// </summary>
    [Parameter] public bool Open { get; set; }
    [Parameter] public EventCallback<bool> OpenChanged { get; set; }

    [Obsolete("Use Open instead. IsOpen will be removed in a future release.")]
    [Parameter] public bool IsOpen { get => Open; set => Open = value; }

    [Obsolete("Use OpenChanged instead. IsOpenChanged will be removed in a future release.")]
    [Parameter] public EventCallback<bool> IsOpenChanged { get => OpenChanged; set => OpenChanged = value; }

    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }

    // Multi-select
    [Parameter] public bool Multiple { get; set; }
    [Parameter] public HashSet<string>? Values { get; set; }
    [Parameter] public EventCallback<HashSet<string>> ValuesChanged { get; set; }

    // Creatable
    [Parameter] public bool Creatable { get; set; }
    [Parameter] public EventCallback<string> OnCreate { get; set; }

    // Async search
    [Parameter] public EventCallback<string> OnSearchAsync { get; set; }
    [Parameter] public int DebounceMs { get; set; } = 300;
    [Parameter] public bool IsSearchLoading { get; set; }

    // Validation
    [Parameter] public bool Required { get; set; }
    [Parameter] public bool Invalid { get; set; }
    [Parameter] public string? ErrorText { get; set; }
    [Parameter] public string? HelperText { get; set; }
    [Parameter] public string? Label { get; set; }
    [Parameter] public string? Name { get; set; }

    // --- Data-bound mode (non-generic, purely additive) ---
    /// <summary>Optional data source. When set, <see cref="ComboboxContent"/> renders options from this list instead of ChildContent.</summary>
    [Parameter] public IEnumerable<object>? Items { get; set; }
    /// <summary>Extracts the string value for each item. Defaults to <c>ToString()</c>.</summary>
    [Parameter] public Func<object, string> ItemValue { get; set; } = it => it?.ToString() ?? "";
    /// <summary>Extracts the display label for each item. Defaults to <see cref="ItemValue"/> when null.</summary>
    [Parameter] public Func<object, string>? ItemText { get; set; }
    /// <summary>Optional group key extractor. When set, options are grouped under sticky headers.</summary>
    [Parameter] public Func<object, string?>? ItemGroup { get; set; }
    /// <summary>Optional per-item disabled predicate.</summary>
    [Parameter] public Func<object, bool>? ItemDisabled { get; set; }
    /// <summary>Optional custom render fragment for an item's content. Receives the raw item object.</summary>
    [Parameter] public RenderFragment<object>? ItemTemplate { get; set; }
    /// <summary>When <c>true</c> and <see cref="Items"/> is set, uses Blazor's Virtualize component for the option list.</summary>
    [Parameter] public bool Virtualize { get; set; }
    /// <summary>Estimated pixel height of each option row used by the Virtualize component. Default 36.</summary>
    [Parameter] public float ItemSize { get; set; } = 36f;

    private bool EffectiveInvalid => Invalid || FormField?.HasError == true;
    private bool InsideFormField => FormField is not null;

    private readonly string _wrapperId = $"combobox-{Guid.NewGuid():N}";
    private readonly string _contentId = $"combobox-content-{Guid.NewGuid():N}";
    private string SearchText { get; set; } = "";
    private readonly DelayedDispatch _dispatch = new();
    private int _focusedIndex = -1;
    private readonly List<(string Id, string Value)> _items = new();

    private void RegisterItem(string id, string value)
    {
        if (!_items.Any(i => i.Id == id))
            _items.Add((id, value));
    }

    private void UnregisterItem(string id)
    {
        _items.RemoveAll(i => i.Id == id);
    }

    private async Task SetFocusedIndex(int index)
    {
        var count = _items.Count;
        if (count == 0) return;
        if (index < 0) index = 0;
        if (index >= count) index = count - 1;
        _focusedIndex = index;
        StateHasChanged();
        await Task.CompletedTask;
    }

    private async Task OnValueSelected(string value)
    {
        if (Multiple)
        {
            Values ??= [];
            if (Values.Contains(value))
                Values.Remove(value);
            else
                Values.Add(value);
            await ValuesChanged.InvokeAsync(Values);
        }
        else
        {
            Value = value;
            await ValueChanged.InvokeAsync(value);
            await SetOpen(false);
        }
    }

    private async Task SetOpen(bool value)
    {
        Open = value;
        if (!value && !Multiple)
        {
            SearchText = "";
            _focusedIndex = -1;
            _items.Clear();
        }
        await OpenChanged.InvokeAsync(value);
    }

    private async Task OnSearchChanged(string text)
    {
        SearchText = text;
        _focusedIndex = -1;

        // Only clear registered items for async search — async replaces the item set, so the old registrations
        // are stale. For static items (filtered by the parent's bound text), the items remain mounted and handle
        // their own register/unregister lifecycle; clearing here mis-reports ItemCount as 0 and triggers the
        // "no results" empty state while items are still visible.
        if (OnSearchAsync.HasDelegate)
        {
            _items.Clear();
            _dispatch.Schedule(DebounceMs, () => InvokeAsync(async () =>
            {
                await OnSearchAsync.InvokeAsync(text);
                StateHasChanged();
            }));
        }

        await Task.CompletedTask;
    }

    private async Task OnCreateItem(string value)
    {
        await OnCreate.InvokeAsync(value);
        if (Multiple)
        {
            Values ??= [];
            Values.Add(value);
            await ValuesChanged.InvokeAsync(Values);
        }
        else
        {
            Value = value;
            await ValueChanged.InvokeAsync(value);
            await SetOpen(false);
        }
        SearchText = "";
    }

    public void Dispose() => _dispatch.Dispose();

    public record ComboboxContext(
        string? Value, HashSet<string>? Values, string SearchText, bool IsOpen, bool Multiple, bool IsSearchLoading, bool Creatable,
        EventCallback<string> OnSelect, EventCallback<bool> SetOpen, EventCallback<string> OnSearch,
        EventCallback<string> OnCreateItem,
        string WrapperId, string ContentId, int FocusedIndex, string? FocusedItemId, string? FocusedItemValue,
        EventCallback<int> SetFocusedIndex,
        Action<string, string> RegisterItem, Action<string> UnregisterItem, int ItemCount,
        bool Invalid, bool Required,
        // Data-bound mode params (null when composition mode)
        IEnumerable<object>? Items,
        Func<object, string> ItemValue,
        Func<object, string>? ItemText,
        Func<object, string?>? ItemGroup,
        Func<object, bool>? ItemDisabled,
        RenderFragment<object>? ItemTemplate,
        bool Virtualize,
        float ItemSize);
}
