@namespace Lumeo
@inject Services.ComponentInteropService Interop
@inject Lumeo.Services.Localization.ILumeoLocalizer L
@implements IAsyncDisposable

@if (_initError is not null)
{
    <div class="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">Editor failed to initialize: @_initError</div>
}
else
{

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

<div class="@RootClass" @attributes="AdditionalAttributes" aria-required="@(Required ? "true" : "false")" aria-invalid="@(EffectiveInvalid ? "true" : "false")">
    @if (EffectiveToolbar != EditorToolbarPreset.None)
    {
        <EditorToolbar Preset="EffectiveToolbar"
                       Active="_active"
                       Disabled="Disabled || ReadOnly"
                       AllowWordImport="AllowWordImport"
                       EnableAiActions="EnableAiActions"
                       OnCommand="HandleToolbarCommand" />
    }

    <div @ref="_contentRef" style="@ContentStyle" class="lumeo-rte-content bg-card"></div>
</div>

@if (!InsideFormField)
{
    @if (_wordImportError is not null)
    {
        <p class="text-xs text-destructive mt-1.5" role="alert">@_wordImportError</p>
    }
    else if (EffectiveInvalid && ErrorText is not null)
    {
        <p class="text-xs text-destructive mt-1.5">@ErrorText</p>
    }
    else if (HelperText is not null)
    {
        <p class="text-xs text-muted-foreground mt-1.5">@HelperText</p>
    }
}

<Dialog @bind-Open="_linkDialogOpen">
    <DialogContent Class="max-w-md">
        <DialogHeader>
            <DialogTitle>@L["Editor.InsertLinkTitle"]</DialogTitle>
            <DialogDescription>@L["Editor.InsertLinkDescription"]</DialogDescription>
        </DialogHeader>
        <div class="py-2">
            <Input @bind-Value="_linkDialogUrl"
                   Placeholder="https://"
                   @onkeydown="HandleLinkDialogKey" />
        </div>
        <DialogFooter>
            <Button Variant="Button.ButtonVariant.Outline" OnClick="CancelLinkDialog">@L["Editor.Cancel"]</Button>
            <Button Variant="Button.ButtonVariant.Default" OnClick="ApplyLinkDialog" Disabled="@string.IsNullOrWhiteSpace(_linkDialogUrl)">
                @L["Editor.Apply"]
            </Button>
        </DialogFooter>
    </DialogContent>
</Dialog>

} @* end @if (_initError is not null) else *@

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

    // ---- Backwards-compatible params ----
    [Parameter] public string? Value { get; set; }
    [Parameter] public EventCallback<string?> ValueChanged { get; set; }
    [Parameter] public string? Placeholder { get; set; }
    private string EffectivePlaceholder => Placeholder ?? L["Editor.StartWriting"];
    [Parameter] public int MinHeight { get; set; } = 180;
    [Parameter] public int? MaxHeight { get; set; }
    [Parameter] public bool Disabled { get; set; }
    [Parameter] public bool ReadOnly { get; set; }
    [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; }

    /// <summary>Legacy toolbar mode; superseded by <see cref="Toolbar"/>.</summary>
    [Parameter] public RichTextToolbarMode? ToolbarMode { get; set; }

    /// <summary>Toolbar density preset. Defaults to <see cref="EditorToolbarPreset.Standard"/>.</summary>
    [Parameter] public EditorToolbarPreset Toolbar { get; set; } = EditorToolbarPreset.Standard;

    // ---- New: rich features ----
    [Parameter] public IReadOnlyList<EditorTrigger>? Triggers { get; set; }
    [Parameter] public bool EnableSlashCommand { get; set; } = true;
    [Parameter] public bool EnableTables { get; set; } = true;
    [Parameter] public bool EnableCodeBlock { get; set; } = true;
    [Parameter] public bool EnableImages { get; set; } = true;
    [Parameter] public bool EnableTaskList { get; set; } = true;
    [Parameter] public bool EnableMarkdownShortcuts { get; set; } = true;
    [Parameter] public bool EnableAiActions { get; set; }
    [Parameter] public bool EnableBubbleMenu { get; set; } = true;
    [Parameter] public bool EnableDragHandle { get; set; } = true;
    [Parameter] public int? MaxLength { get; set; }
    [Parameter] public bool AllowWordImport { get; set; }

    /// <summary>
    /// Maximum size, in bytes, of a Word document accepted by the in-browser import path.
    /// Defaults to 8 MB. Larger files are refused before being read into memory and surfaced
    /// to the user via <see cref="WordImportError"/>. Set explicitly when consumers expect
    /// large documents and the deployment can afford the memory.
    /// </summary>
    [Parameter] public long MaxWordImportBytes { get; set; } = 8L * 1024L * 1024L;

    /// <summary>Resolves a picked image into a URL (or data URI). Receives file metadata
    /// from JS; consumer is expected to upload the file out-of-band (e.g. via a paired
    /// <c>InputFile</c>) and return a final URL. If null, images fall back to base64.</summary>
    [Parameter] public Func<EditorImageRequest, ValueTask<string?>>? OnImageRequested { get; set; }

    /// <summary>Selection-driven AI actions. Receives the action id and selected plain text;
    /// returns replacement HTML to be inserted in place of the selection.</summary>
    [Parameter] public Func<string, string, ValueTask<string?>>? OnAiAction { get; set; }

    /// <summary>Optional override for Word import. Receives the picked file payload
    /// (filename + bytes) and returns HTML to replace the editor content. When NOT set,
    /// the file is converted in-browser via <see cref="WordImporter"/> (Mammoth runs
    /// inside the WASM bundle), so the demo works on static hosting with no server.
    /// Set this when you want server-side conversion (better fidelity for complex docs).</summary>
    [Parameter] public Func<WordImportPayload, ValueTask<string>>? OnWordImportRequested { get; set; }

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

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

    private EditorToolbarPreset EffectiveToolbar => ToolbarMode switch
    {
        RichTextToolbarMode.None => EditorToolbarPreset.None,
        RichTextToolbarMode.Basic => EditorToolbarPreset.Minimal,
        RichTextToolbarMode.Full => EditorToolbarPreset.Full,
        _ => Toolbar,
    };

    private Microsoft.AspNetCore.Components.ElementReference _contentRef;
    private DotNetObjectReference<RichTextEditor>? _selfRef;
    private string? _instanceId;
    private string? _lastSetValue;
    private bool _lastDisabled;
    private bool _lastReadOnly;
    private Services.Interop.RichTextActiveState? _active;
    private bool _initialized;
    private string? _initError;

    // Link dialog state (replaces the native browser prompt for the toolbar
    // and bubble menu's Link button).
    private bool _linkDialogOpen;
    private string _linkDialogUrl = string.Empty;

    // Surface the most-recent Word import failure (size cap, decode error, etc.)
    // inline below the editor — same slot the EffectiveInvalid path uses.
    private string? _wordImportError;

    private void OpenLinkDialog(string? initial)
    {
        _linkDialogUrl = string.IsNullOrWhiteSpace(initial) ? "https://" : initial;
        _linkDialogOpen = true;
        StateHasChanged();
    }

    private void CancelLinkDialog()
    {
        _linkDialogOpen = false;
    }

    private async Task ApplyLinkDialog()
    {
        var url = _linkDialogUrl?.Trim();
        _linkDialogOpen = false;
        if (string.IsNullOrWhiteSpace(url) || _instanceId is null) return;
        await Interop.RichTextCommandAsync(_instanceId, "link", new object?[] { url });
    }

    private async Task HandleLinkDialogKey(KeyboardEventArgs e)
    {
        if (e.Key == "Enter") await ApplyLinkDialog();
        else if (e.Key == "Escape") CancelLinkDialog();
    }

    [JSInvokable("OnLinkRequested")]
    public Task OnLinkRequestedJs(string? currentUrl)
    {
        OpenLinkDialog(currentUrl);
        return Task.CompletedTask;
    }

    private string RootClass =>
        $"rounded-lg border {(EffectiveInvalid ? "border-destructive focus-within:ring-destructive/20" : "border-border focus-within:border-ring/60 focus-within:ring-ring/40")} bg-card overflow-hidden transition-colors focus-within:ring-[1.5px] {Class}".Trim();

    private string ContentStyle
    {
        get
        {
            var parts = new List<string> { $"min-height: {MinHeight}px" };
            if (MaxHeight is int max)
            {
                parts.Add($"max-height: {max}px");
                parts.Add("overflow-y: auto");
            }
            return string.Join("; ", parts) + ";";
        }
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _selfRef = DotNetObjectReference.Create(this);
            _lastSetValue = Value;
            _lastDisabled = Disabled;
            _lastReadOnly = ReadOnly;

            // Build the "trigger" descriptor list passed into JS — only the
            // metadata the JS needs (char + label). Item resolution happens via
            // the OnTriggerQuery JSInvokable below, which fans queries out to
            // the matching EditorTrigger registered server-side.
            var triggerMeta = (Triggers ?? Array.Empty<EditorTrigger>())
                .Select(t => new { @char = t.Char.ToString(), label = $"{t.Char} suggestions" })
                .ToArray();

            var options = new
            {
                content = Value ?? string.Empty,
                initialHtml = Value ?? string.Empty,
                placeholder = EffectivePlaceholder,
                disabled = Disabled,
                readOnly = ReadOnly,
                editable = !Disabled && !ReadOnly,
                mentionTriggers = triggerMeta,
                enableSlashCommand = EnableSlashCommand,
                enableTables = EnableTables,
                enableCodeBlock = EnableCodeBlock,
                enableImages = EnableImages,
                enableTaskList = EnableTaskList,
                enableMarkdownShortcuts = EnableMarkdownShortcuts,
                enableAiActions = EnableAiActions,
                enableBubbleMenu = EnableBubbleMenu,
                enableDragHandle = EnableDragHandle,
                maxLength = MaxLength,
                maxWordImportBytes = MaxWordImportBytes,
            };

            try
            {
                _instanceId = await Interop.RichTextInitAsync(_contentRef, _selfRef, options);
            }
            catch (JSException ex)
            {
                _initError = ex.Message;
                Console.Error.WriteLine($"[Lumeo RichTextEditor] Init failed: {ex.Message}");
                if (OnInitError.HasDelegate)
                    await OnInitError.InvokeAsync(ex.Message);
                StateHasChanged();
                return;
            }
            _initialized = !string.IsNullOrEmpty(_instanceId);
            if (_initialized)
            {
                await RefreshActiveAsync();
            }
        }
    }

    protected override async Task OnParametersSetAsync()
    {
        if (!_initialized || _instanceId is null) return;

        if (!string.Equals(Value, _lastSetValue, StringComparison.Ordinal))
        {
            _lastSetValue = Value;
            await Interop.RichTextSetContentAsync(_instanceId, Value);
        }

        if (Disabled != _lastDisabled || ReadOnly != _lastReadOnly)
        {
            _lastDisabled = Disabled;
            _lastReadOnly = ReadOnly;
            await Interop.RichTextSetDisabledAsync(_instanceId, Disabled || ReadOnly);
        }
    }

    // ---------------- JS callbacks ----------------

    [JSInvokable]
    public async Task OnContentUpdate(string html)
    {
        _lastSetValue = html;
        Value = html;
        await ValueChanged.InvokeAsync(html);
        await RefreshActiveAsync();
    }

    /// <summary>Mirror of <see cref="OnContentUpdate"/> kept for the new JS naming.</summary>
    [JSInvokable]
    public Task OnUpdate(string html) => OnContentUpdate(html);

    [JSInvokable]
    public Task OnSelectionUpdate() => RefreshActiveAsync();

    [JSInvokable]
    public async Task<IReadOnlyList<TriggerItem>> OnTriggerQuery(string triggerChar, string query)
    {
        // Slash command default — only kicks in if no consumer trigger overrides '/'.
        if (triggerChar == "/" && (Triggers is null || !Triggers.Any(t => t.Char == '/')))
        {
            return EnableSlashCommand
                ? await SlashCommands.Filter(query, L)
                : Array.Empty<TriggerItem>();
        }

        var trigger = Triggers?.FirstOrDefault(t => t.Char.ToString() == triggerChar);
        if (trigger is null) return Array.Empty<TriggerItem>();
        try
        {
            return await trigger.ItemSource(query) ?? Array.Empty<TriggerItem>();
        }
        catch
        {
            return Array.Empty<TriggerItem>();
        }
    }

    [JSInvokable("OnImageRequested")]
    public async Task<string?> OnImageRequestedJs(string fileName, string mimeType, long size)
    {
        if (OnImageRequested is null) return null;
        try
        {
            return await OnImageRequested(new EditorImageRequest(fileName, mimeType, size));
        }
        catch
        {
            return null;
        }
    }

    [JSInvokable("OnAiAction")]
    public async Task<string?> OnAiActionJs(string actionId, string selectedText)
    {
        if (OnAiAction is null) return null;
        try
        {
            return await OnAiAction(actionId, selectedText);
        }
        catch
        {
            return null;
        }
    }

    [JSInvokable("OnWordImportRequested")]
    public async Task OnWordImportRequestedJs(string fileName, string base64Content)
    {
        try
        {
            // Pre-allocation size guard — decoded length is computed from the base64
            // string length, so an oversized payload is rejected BEFORE the .NET
            // byte[] allocation that would normally double peak memory usage.
            var bytes = WordImporter.DecodeBase64WithLimit(base64Content, MaxWordImportBytes);
            var payload = new WordImportPayload(fileName, bytes);

            string html;
            if (OnWordImportRequested is not null)
            {
                html = await OnWordImportRequested(payload);
            }
            else
            {
                using var stream = new MemoryStream(bytes);
                var result = await WordImporter.ToHtmlAsync(stream);
                html = result.Html;
            }

            if (!string.IsNullOrEmpty(html))
            {
                _wordImportError = null;
                await SetHtmlAsync(html);
            }
        }
        catch (WordImportSizeException sizeEx)
        {
            _wordImportError = $"Word document too large ({FormatBytes(sizeEx.ActualBytes)}). Limit is {FormatBytes(sizeEx.LimitBytes)}.";
            StateHasChanged();
        }
        catch (Exception ex)
        {
            _wordImportError = "Word import failed. Try saving the document again or pick a different file.";
            Console.Error.WriteLine($"Word import failed: {ex.Message}");
            StateHasChanged();
        }
    }

    [JSInvokable("OnWordImportFailed")]
    public Task OnWordImportFailedJs(string reason, long size, long limit)
    {
        _wordImportError = reason switch
        {
            "file_too_large" => $"Word document too large ({FormatBytes(size)}). Limit is {FormatBytes(limit)}.",
            _ => "Word import failed. Try saving the document again or pick a different file.",
        };
        StateHasChanged();
        return Task.CompletedTask;
    }

    private static string FormatBytes(long bytes)
    {
        if (bytes < 1024) return $"{bytes} B";
        if (bytes < 1024 * 1024) return $"{bytes / 1024.0:0.#} KB";
        return $"{bytes / (1024.0 * 1024.0):0.#} MB";
    }

    // ---------------- Toolbar dispatch ----------------

    private async Task HandleToolbarCommand((string command, object? arg) cmd)
    {
        if (_instanceId is null || Disabled || ReadOnly) return;

        switch (cmd.command)
        {
            case "link":
            {
                OpenLinkDialog(null);
                break;
            }
            case "ai-menu":
                // Reserved for a future popover. For now, fire an "improve" against
                // current selection if the consumer wired OnAiAction.
                if (OnAiAction is not null)
                {
                    await Interop.RichTextCommandAsync(_instanceId, "noop", null);
                }
                break;
            default:
                var args = cmd.arg is null ? null : new object?[] { cmd.arg };
                await Interop.RichTextCommandAsync(_instanceId, cmd.command, args);
                break;
        }

        await RefreshActiveAsync();
    }

    private async Task RefreshActiveAsync()
    {
        if (_instanceId is null) return;
        _active = await Interop.RichTextGetActiveAsync(_instanceId);
        StateHasChanged();
    }

    // ---------------- Public API for consumers ----------------

    /// <summary>Replaces the editor content with the given HTML.</summary>
    public async ValueTask SetHtmlAsync(string? html)
    {
        if (_instanceId is null) return;
        _lastSetValue = html;
        Value = html;
        await Interop.RichTextSetContentAsync(_instanceId, html);
        await ValueChanged.InvokeAsync(html);
    }

    public async ValueTask DisposeAsync()
    {
        if (_instanceId is not null)
        {
            try
            {
                await Interop.RichTextDestroyAsync(_instanceId);
            }
            catch (JSDisconnectedException) { /* ignore — circuit already gone */ }
            _instanceId = null;
        }
        _selfRef?.Dispose();
        _selfRef = null;
    }

    /// <summary>Legacy toolbar mode — kept so existing call sites compile.</summary>
    public enum RichTextToolbarMode
    {
        None,
        Basic,
        Full,
    }
}
