/* panel.js - Strata clipboard popup panel. */

import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';

import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { ClipboardItem } from './clipboardItem.js';

const SEARCH_DEBOUNCE_MS = 150;
const LOAD_MORE_THRESHOLD = 200;

export class StrataPanel {
    constructor(proxy, settings, indicator = null) {
        this._proxy = proxy;
        this._settings = settings;
        this._indicator = indicator; // PanelMenu.Button - used to avoid toggle-reopen race
        this._pageSize = settings.get_int('page-size');
        this._pageSizeChangedId = settings.connect('changed::page-size',
            () => { this._pageSize = settings.get_int('page-size'); });
        /** @type {{ id: string, mimeType: string, preview: string }[]} */
        this._items = [];
        /** @type {Map<string, ClipboardItem>} id → widget */
        this._widgets = new Map();
        /** @type {Map<string, string>} id → cache file path (shared across widget lifetimes) */
        this._thumbCache = new Map();
        /** Pagination state for the non-search view. */
        this._loadedOffset = 0;          // how many items we've already pulled
        this._hasMore = true;            // false once daemon returns < PAGE_SIZE
        this._loadingMore = false;       // re-entrancy guard for scroll-driven loads
        /** Search state. */
        this._searchQuery = '';          // current search string ('' = no search)
        this._searchDebounceId = null;   // pending GLib timeout for debounce
        this._searchEpoch = 0;           // monotonic counter to discard stale search responses
        this._searchResults = [];        // full match snapshot (metadata) for the active query
        this._searchRendered = 0;        // how many of _searchResults have widgets so far
        this._resultsEpoch = -1;         // epoch whose results currently fill _searchResults

        this._visible = false;
        this._grab = null;        // Clutter.Grab from Main.pushModal
        this._hoveredWidget = null; // currently mouse-hovered ClipboardItem
        this._activeWidget = null;  // most recently copied item

        this._buildUI();

        // Theme: apply light/dark to the panel box and react to changes.
        // The light.css overrides (loaded by extension.js) only take effect
        // while the `.strata-theme-light` class is present on `_box`.
        this._interfaceSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
        this._applyTheme();
        this._themeChangedId = this._settings.connect('changed::theme',
            () => this._applyTheme());
        this._colorSchemeChangedId = this._interfaceSettings.connect('changed::color-scheme',
            () => this._applyTheme());

        // Trigger the initial load as soon as the daemon owns its bus name.
        // At extension boot the daemon may still be starting (its D-Bus name
        // not yet owned); the proxy fires notify::g-name-owner when that
        // changes, so we listen for the first transition to a non-null owner
        // and only then issue GetHistory. If the daemon is already up, the
        // owner is already set and we load immediately.
        this._tryInitialLoad();
        this._nameOwnerId = this._proxy.connect('notify::g-name-owner',
            () => this._tryInitialLoad());
    }

    /** True when the effective theme is light. `auto` follows the system
     *  color-scheme (anything other than prefer-dark counts as light). */
    _effectiveIsLight() {
        const mode = this._settings.get_string('theme');
        if (mode === 'light') return true;
        if (mode === 'dark') return false;
        return this._interfaceSettings.get_string('color-scheme') !== 'prefer-dark';
    }

    /** Toggle the light-theme class on the panel root box. */
    _applyTheme() {
        if (!this._box) return;
        if (this._effectiveIsLight())
            this._box.add_style_class_name('strata-theme-light');
        else
            this._box.remove_style_class_name('strata-theme-light');
    }

    _tryInitialLoad() {
        if (this._initialLoaded) return;
        if (!this._proxy.g_name_owner) return;
        this._initialLoaded = true;
        this._loadHistory(0, this._pageSize).catch(e =>
            console.error('[Strata] initial _loadHistory failed:', e));
    }


    _buildUI() {
        // Overlay container - sits above all windows.
        this._overlay = new St.Widget({
            layout_manager: new Clutter.FixedLayout(),
            visible: false,
            reactive: true,
        });
        Main.layoutManager.addChrome(this._overlay);

        // Close when clicking outside the panel box.
        // With pushModal active, ALL pointer events are delivered to _overlay,
        // so this handler sees every click on screen.
        this._overlay.connect('button-press-event', (_actor, event) => {
            const [cx, cy] = event.get_coords();
            const [bx, by] = this._box.get_transformed_position();
            const [bw, bh] = this._box.get_transformed_size();
            if (cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh)
                return Clutter.EVENT_PROPAGATE;
            this.close();
            return Clutter.EVENT_STOP;
        });

        // pushModal intercepts all motion events at the overlay level, so CSS
        // :hover never fires on child actors. Use event.get_source() to let
        // Clutter do the hit-test, then walk up to find the owning ClipboardItem.
        // This is O(tree-depth) instead of O(n-widgets) per mouse-move event.
        this._overlay.connect('motion-event', (_actor, event) => {
            let source = event.get_source();
            let found = null;
            while (source && source !== this._overlay) {
                if (source instanceof ClipboardItem) {
                    found = source;
                    break;
                }
                source = source.get_parent();
            }
            if (found !== this._hoveredWidget) {
                this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
                this._hoveredWidget = found;
                found?.add_style_class_name('strata-item-hovered');
            }
            return Clutter.EVENT_PROPAGATE;
        });

        this._overlay.connect('leave-event', () => {
            this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
            this._hoveredWidget = null;
            return Clutter.EVENT_PROPAGATE;
        });

        // Panel box
        this._box = new St.BoxLayout({
            style_class: 'strata-panel',
            vertical: true,
            reactive: true,
        });

        // Header row: title + clear button
        const header = new St.BoxLayout({
            style_class: 'strata-header',
            x_expand: true,
        });
        const title = new St.Label({
            text: 'Clipboard',
            style_class: 'strata-title',
            x_expand: true,
            y_align: Clutter.ActorAlign.CENTER,
        });
        const clearBtn = new St.Button({
            label: 'Clear all',
            style_class: 'strata-clear-btn',
            y_align: Clutter.ActorAlign.CENTER,
        });
        clearBtn.connect('clicked', () => this._clearAll());
        header.add_child(title);
        header.add_child(clearBtn);

        // Search box
        this._searchEntry = new St.Entry({
            hint_text: 'Search…',
            style_class: 'strata-search',
            x_expand: true,
            can_focus: true,
        });
        // Tag the placeholder label so the light theme can recolor it. By
        // default it inherits a light-on-dark Shell color that is illegible on
        // the light panel; dark mode is unaffected (no base rule).
        this._searchEntry.get_hint_actor()?.add_style_class_name('strata-search-hint');
        this._searchEntry.get_clutter_text().connect('text-changed', () => {
            this._scheduleSearch(this._searchEntry.get_text());
        });
        // Down arrow from search box moves focus to the first item.
        this._searchEntry.get_clutter_text().connect('key-press-event', (_actor, event) => {
            if (event.get_key_symbol() === Clutter.KEY_Down) {
                this._focusItem(0);
                return Clutter.EVENT_STOP;
            }
            return Clutter.EVENT_PROPAGATE;
        });

        // Item list
        this._scrollView = new St.ScrollView({
            style_class: 'strata-scroll',
            x_expand: true,
            y_expand: true,
            overlay_scrollbars: true,
        });
        this._itemList = new St.BoxLayout({
            style_class: 'strata-item-list',
            vertical: true,
            x_expand: true,
        });
        this._scrollView.set_child(this._itemList);

        // Load more items when scrolled near the bottom.
        const vadj = this._scrollView.get_vadjustment();
        if (vadj) {
            vadj.connect('notify::value', () => this._maybeLoadMore());
            // Also re-check when the list grows (a new page just appended).
            vadj.connect('notify::upper', () => this._maybeLoadMore());
        }

        // Assemble
        this._box.add_child(header);
        this._box.add_child(this._searchEntry);
        this._box.add_child(this._scrollView);
        this._overlay.add_child(this._box);

        // ESC to close
        this._overlay.connect('key-press-event', (_actor, event) => {
            if (event.get_key_symbol() === Clutter.KEY_Escape) {
                this.close();
                return Clutter.EVENT_STOP;
            }
            return Clutter.EVENT_PROPAGATE;
        });
    }


    toggle() {
        this._visible ? this.close() : this.open();
    }

    open() {
        if (this._visible) return;
        this._visible = true;
        this._overlay.show();
        this._positionPanel();
        // Re-position after one frame so bottom/center use the fully allocated height.
        GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
            if (this._visible) this._positionPanel();
            return GLib.SOURCE_REMOVE;
        });

        // pushModal grabs ALL input and delivers it to _overlay - this is the
        // proper GNOME Shell pattern for "click outside to close" dialogs.
        this._grab = Main.pushModal(this._overlay, {
            actionMode: Shell.ActionMode.POPUP,
        });

        this._searchEntry.set_text('');
        // Reset to non-search view - but DON'T reload; history may already be populated.
        this._searchQuery = '';
        // If the initial history load failed (e.g. daemon wasn't ready at startup),
        // retry now that the user is asking to see the list.
        if (this._items.length === 0 && this._loadedOffset === 0 && this._hasMore) {
            this._loadHistory(0, this._pageSize).catch(e =>
                console.error('[Strata] open: history reload failed:', e));
        }
        global.stage.set_key_focus(this._searchEntry.get_clutter_text());
    }

    close() {
        if (!this._visible) return;
        this._visible = false;
        // Clear hover state since the panel is closing.
        this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
        this._hoveredWidget = null;
        if (this._grab) {
            Main.popModal(this._grab);
            this._grab = null;
        }
        this._overlay.hide();
    }

    /** Prepend a newly added item. Wrapped in try/catch so one bad item can't
     *  break subsequent additions. */
    prependItem(id, mimeType, preview) {
        try {
            this._items.unshift({ id, mimeType, preview });
            this._loadedOffset += 1;

            if (this._widgets.has(id)) {
                this._widgets.get(id)?.destroy();
                this._widgets.delete(id);
            }

            // While searching, only render if the new item matches the active query.
            if (this._searchQuery && !this._matchesSearch(preview, mimeType)) {
                return;
            }

            const widget = this._makeItemWidget(id, mimeType, preview);
            this._widgets.set(id, widget);
            this._itemList.insert_child_at_index(widget.actor, 0);
            this._setActiveWidget(widget);
        } catch (e) {
            console.error(`[Strata] prependItem failed for id=${id} mime=${mimeType}:`, e);
            this._items = this._items.filter(i => i.id !== id);
        }
    }

    // Substring match used for newly-arrived items while a search is active.
    // Approximates FTS5 prefix match until the next debounced search refresh.
    _matchesSearch(preview, mimeType) {
        if (mimeType?.startsWith('image/')) return false;
        if (!preview) return false;
        const haystack = preview.toLowerCase();
        return this._searchQuery.toLowerCase().split(/\s+/)
            .filter(Boolean).every(tok => haystack.includes(tok));
    }

    removeItem(id) {
        this._items = this._items.filter(i => i.id !== id);
        // Keep the search snapshot in sync so a deleted/pruned item sitting in
        // the not-yet-rendered tail can't be re-created as a phantom row when
        // scrolling appends the next page. Adjust _searchRendered if the removed
        // item was within the already-rendered range so the index stays aligned.
        const sIdx = this._searchResults.findIndex(r => r.id === id);
        if (sIdx !== -1) {
            this._searchResults.splice(sIdx, 1);
            if (sIdx < this._searchRendered) this._searchRendered--;
        }
        const widget = this._widgets.get(id);
        if (widget) {
            const wasActive  = widget === this._activeWidget;
            const wasHovered = widget === this._hoveredWidget;
            if (wasActive)  this._activeWidget  = null;
            if (wasHovered) this._hoveredWidget = null;
            widget.destroy();
            this._widgets.delete(id);
            // Re-assign active to the new first visible item.
            if (wasActive) {
                const first = this._getVisibleItems()[0];
                if (first) this._setActiveWidget(first);
            }
        }
    }

    clearItems() {
        this._items = [];
        this._searchResults = [];
        this._searchRendered = 0;
        this._resultsEpoch = -1; // invalidate the search snapshot so a scroll can't render stale rows
        this._hoveredWidget = null;
        this._activeWidget  = null;
        this._widgets.clear();
        this._itemList.destroy_all_children();
    }

    destroy() {
        if (this._pageSizeChangedId) {
            this._settings.disconnect(this._pageSizeChangedId);
            this._pageSizeChangedId = 0;
        }
        if (this._themeChangedId) {
            this._settings.disconnect(this._themeChangedId);
            this._themeChangedId = 0;
        }
        if (this._colorSchemeChangedId && this._interfaceSettings) {
            this._interfaceSettings.disconnect(this._colorSchemeChangedId);
            this._colorSchemeChangedId = 0;
        }
        this._interfaceSettings = null;
        if (this._nameOwnerId) {
            this._proxy.disconnect(this._nameOwnerId);
            this._nameOwnerId = 0;
        }
        if (this._searchDebounceId) {
            GLib.Source.remove(this._searchDebounceId);
            this._searchDebounceId = null;
        }
        this.close();
        this.clearItems();
        Main.layoutManager.removeChrome(this._overlay);
        this._overlay.destroy();
        this._overlay = null;
    }


    async _loadHistory(offset, limit) {
        if (!this._overlay) return 0;
        try {
            const [json] = await this._proxy.GetHistoryAsync(offset, limit);
            if (!this._overlay) return 0;
            const items = JSON.parse(json);
            const BATCH = 20;
            for (let i = 0; i < items.length; i += BATCH) {
                if (!this._overlay) return items.length;
                await new Promise(resolve =>
                    GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
                        const end = Math.min(i + BATCH, items.length);
                        for (let j = i; j < end; j++) this._appendItemFromMeta(items[j]);
                        resolve();
                        return GLib.SOURCE_REMOVE;
                    })
                );
            }
            this._loadedOffset = offset + items.length;
            this._hasMore = items.length >= limit;
            if (offset === 0 && items.length > 0) {
                const firstWidget = this._widgets.get(items[0].id);
                if (firstWidget) this._setActiveWidget(firstWidget);
            }
            return items.length;
        } catch (e) {
            console.error('[Strata] _loadHistory failed:', e);
            return 0;
        }
    }

    _appendItemFromMeta(meta) {
        try {
            const id = meta.id;
            const mimeType = meta.mime_type;
            const preview = meta.content_text ?? '';
            if (this._widgets.has(id)) return;

            this._items.push({ id, mimeType, preview });
            const widget = this._makeItemWidget(id, mimeType, preview);
            this._widgets.set(id, widget);
            this._itemList.add_child(widget.actor);
        } catch (e) {
            console.error(`[Strata] _appendItemFromMeta failed for id=${meta?.id}:`, e);
        }
    }

    _maybeLoadMore() {
        if (this._loadingMore) return;
        const adj = this._scrollView.get_vadjustment();
        if (!adj || adj.upper <= adj.page_size) return;
        const distanceToBottom = adj.upper - (adj.value + adj.page_size);
        if (distanceToBottom > LOAD_MORE_THRESHOLD) return;

        if (this._searchQuery) {
            // Search mode: render the next page from the in-memory match
            // snapshot (no re-query, so a scroll cannot race the search).
            this._renderSearchPage(this._searchEpoch);
        } else {
            // Browse mode: pull the next page from the daemon.
            if (!this._hasMore) return;
            this._loadingMore = true;
            this._loadHistory(this._loadedOffset, this._pageSize)
                .finally(() => { this._loadingMore = false; });
        }
    }

    /** Render the next page-size slice of `_searchResults` into the list.
     *  Bounded per call so a broad query never builds every match at once;
     *  scrolling calls this again until the snapshot is exhausted. */
    async _renderSearchPage(epoch) {
        if (epoch !== this._searchEpoch || !this._overlay) return;
        // The snapshot must belong to this epoch. During a new query's fetch the
        // epoch is already bumped while _searchResults still holds the previous
        // set; rendering then would paint stale rows (and race the real render).
        if (this._resultsEpoch !== epoch) return;
        if (this._loadingMore) return;
        const start = this._searchRendered;
        if (start >= this._searchResults.length) return;

        this._loadingMore = true;
        try {
            const end = Math.min(start + this._pageSize, this._searchResults.length);
            const BATCH = 20;
            for (let i = start; i < end; i += BATCH) {
                if (epoch !== this._searchEpoch || !this._overlay) return;
                const renderChunk = () => {
                    const e = Math.min(i + BATCH, end);
                    for (let j = i; j < e; j++)
                        this._appendItemFromMeta(this._searchResults[j]);
                };
                // The very first chunk of a fresh render (start === 0, right after
                // _clearListDom) is rendered synchronously, in the same frame as
                // the clear, so the list never paints empty in between - that
                // empty frame is the "blink" seen while typing a search. Later
                // chunks (and scroll-driven pages) yield to idle to keep frames
                // light.
                if (i === 0) {
                    renderChunk();
                } else {
                    await new Promise(resolve =>
                        GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
                            renderChunk();
                            resolve();
                            return GLib.SOURCE_REMOVE;
                        }));
                }
            }
            this._searchRendered = end;
        } finally {
            // Only release the guard if we still own the current search. If a
            // newer search superseded us mid-render, it now owns the guard and
            // must not be cleared by this stale render.
            if (epoch === this._searchEpoch) this._loadingMore = false;
        }
    }


    _makeItemWidget(id, mimeType, preview) {
        const widget = new ClipboardItem(id, mimeType, preview, {
            proxy: this._proxy,
            thumbCache: this._thumbCache,
        });
        widget.connect('activate', () => {
            this._setActiveWidget(widget);
            if (this._settings?.get_boolean('move-activated-to-top'))
                this._moveItemToTop(id, widget);
            this._onItemActivated(id);
        });
        widget.connect('delete', () => {
            this._proxy.DeleteItemRemote(id, () => {});
        });
        widget.connect('key-press-event', (_actor, event) => {
            const sym = event.get_key_symbol();
            const items = this._getVisibleItems();
            const idx = items.indexOf(widget);
            if (sym === Clutter.KEY_Down) {
                if (idx < items.length - 1) {
                    global.stage.set_key_focus(items[idx + 1]);
                    this._ensureVisible(items[idx + 1]);
                }
                return Clutter.EVENT_STOP;
            }
            if (sym === Clutter.KEY_Up) {
                if (idx <= 0) {
                    global.stage.set_key_focus(this._searchEntry.get_clutter_text());
                } else {
                    global.stage.set_key_focus(items[idx - 1]);
                    this._ensureVisible(items[idx - 1]);
                }
                return Clutter.EVENT_STOP;
            }
            if (sym === Clutter.KEY_Escape) {
                this.close();
                return Clutter.EVENT_STOP;
            }
            return Clutter.EVENT_PROPAGATE;
        });
        return widget;
    }

    /** Focus the item at visible-list index `idx` (clamped). */
    _focusItem(idx) {
        const items = this._getVisibleItems();
        if (items.length === 0) return;
        global.stage.set_key_focus(items[Math.max(0, Math.min(idx, items.length - 1))]);
    }

    /** All currently-visible item actors in order. */
    _getVisibleItems() {
        return this._itemList.get_children().filter(c => c.visible);
    }

    _ensureVisible(widget) {
        GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
            if (!this._overlay) return GLib.SOURCE_REMOVE; // panel destroyed before idle ran
            try {
                const adj = this._scrollView.get_vadjustment();
                if (!adj || adj.page_size === 0) return GLib.SOURCE_REMOVE;

                // Allocation box coords share the same space as adj.value.
                const box = widget.get_allocation_box();
                const cur = adj.value;
                const pageSize = adj.page_size;

                if (box.y1 < cur)
                    adj.value = box.y1;
                else if (box.y2 > cur + pageSize)
                    adj.value = box.y2 - pageSize;
            } catch (e) {
                console.error('[Strata] ensureVisible error:', e);
            }
            return GLib.SOURCE_REMOVE;
        });
    }

    async _onItemActivated(id) {
        try {
            const [mimeType, content] = await this._proxy.GetItemContentAsync(id);
            this._writeToClipboard(mimeType, content);
        } catch (e) {
            console.error('[Strata] Paste error:', e);
        }
        this.close();
    }

    _writeToClipboard(mimeType, bytes) {
        try {
            if (mimeType.startsWith('text/') || mimeType === 'UTF8_STRING') {
                const text = new TextDecoder('utf-8').decode(bytes);
                St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, text);
            } else {
                const source = Meta.SelectionSourceMemory.new(
                    mimeType,
                    GLib.Bytes.new(bytes)
                );
                global.display.get_selection().set_owner(
                    Meta.SelectionType.SELECTION_CLIPBOARD,
                    source
                );
            }
        } catch (e) {
            console.error('[Strata] Clipboard write error:', e);
        }
    }

    async _clearAll() {
        try {
            await this._proxy.ClearHistoryAsync();
        } catch (e) {
            console.error('[Strata] ClearHistory error:', e);
        }
    }

    /** Debounce search box keystrokes; collapse rapid edits into one query. */
    _scheduleSearch(query) {
        if (this._searchDebounceId) {
            GLib.Source.remove(this._searchDebounceId);
            this._searchDebounceId = null;
        }
        const trimmed = (query ?? '').trim();
        this._searchDebounceId = GLib.timeout_add(
            GLib.PRIORITY_DEFAULT, SEARCH_DEBOUNCE_MS, () => {
                this._searchDebounceId = null;
                this._runSearch(trimmed).catch(e =>
                    console.error('[Strata] search failed:', e));
                return GLib.SOURCE_REMOVE;
            });
    }

    /** Apply a search query (empty = reset to the recent-history view).
     *  All state-mutating writes are guarded with an epoch so a fast-cancelled
     *  search cannot overwrite the results of a newer one. */
    async _runSearch(query) {
        const epoch = ++this._searchEpoch;
        // A new search (or reset) supersedes any in-flight page render. Release
        // the shared loading guard so this fresh render is never blocked by a
        // stale one; the stale render bails on its own epoch check and, per the
        // epoch-ownership check in _renderSearchPage, won't clear the guard out
        // from under us.
        this._loadingMore = false;
        if (!query) {
            this._searchQuery = '';
            this._searchResults = [];
            this._searchRendered = 0;
            this._clearListDom();
            this._items = [];
            this._loadedOffset = 0;
            this._hasMore = true;
            // Don't await: if the user starts a new search mid-load, the epoch
            // guard inside _appendItemFromMeta's caller would still let stale
            // rows leak in. So check the epoch before letting any rows in.
            const ePromise = this._loadHistory(0, this._pageSize);
            ePromise.then(() => {
                if (epoch !== this._searchEpoch) {
                    // A newer search superseded us; nuke whatever we appended.
                    this._clearListDom();
                    this._items = [];
                }
            }).catch(e => console.error('[Strata] reset-history failed:', e));
            return;
        }

        this._searchQuery = query;
        // Fetch the full match set, bounded by the configured history size, so
        // search covers everything that is stored (not an arbitrary cap).
        const limit = this._settings.get_int('max-history');
        let json;
        try {
            [json] = await this._proxy.SearchHistoryAsync(query, limit);
        } catch (e) {
            console.error('[Strata] SearchHistory D-Bus error:', e);
            return;
        }
        if (epoch !== this._searchEpoch || !this._overlay) return; // stale or destroyed

        let results;
        try {
            results = JSON.parse(json);
        } catch (e) {
            console.error('[Strata] SearchHistory: bad JSON:', e);
            return;
        }

        this._clearListDom();
        this._items = [];
        // Snapshot the full match set and render it lazily, a page at a time,
        // so a broad query on a large history never builds thousands of row
        // widgets up front. Browse-mode pagination stays off; search paging is
        // driven by _renderSearchPage over this snapshot (see _maybeLoadMore).
        this._searchResults = results;
        this._searchRendered = 0;
        this._resultsEpoch = epoch;
        this._hasMore = false;
        this._loadedOffset = 0;

        await this._renderSearchPage(epoch);
    }

    /** Tear down all rendered item widgets (without touching the data model). */
    _clearListDom() {
        this._hoveredWidget = null;
        this._activeWidget = null;
        this._widgets.forEach(w => w.destroy());
        this._widgets.clear();
        this._itemList.destroy_all_children();
    }

    _setActiveWidget(widget) {
        this._activeWidget?.remove_style_class_name('strata-item-active');
        this._activeWidget = widget ?? null;
        widget?.add_style_class_name('strata-item-active');
    }

    _moveItemToTop(id, widget) {
        // Move data model entry to front.
        const idx = this._items.findIndex(i => i.id === id);
        if (idx > 0) {
            const [entry] = this._items.splice(idx, 1);
            this._items.unshift(entry);
        }
        // Move actor to position 0 in the list.
        this._itemList.set_child_at_index(widget, 0);
    }

    _positionPanel() {
        const monitor = Main.layoutManager.primaryMonitor;
        if (!monitor) return;

        const PANEL_W = Math.min(
            this._settings?.get_int('panel-width') ?? 480,
            monitor.width * 0.9
        );
        const MAX_H = Math.min(
            this._settings?.get_int('panel-max-height') ?? 600,
            monitor.height * 0.85
        );

        this._box.set_width(PANEL_W);
        // Let height shrink to content; cap at MAX_H so the scroll view kicks in
        // when there are many items.
        this._box.set_height(-1);
        this._box.style = `max-height: ${MAX_H}px;`;

        const position = this._settings?.get_string('panel-position') ?? 'top-center';
        const MARGIN = 16; // px gap from screen edge

        let x, y;
        // Horizontal
        if (position.endsWith('left'))
            x = monitor.x + MARGIN;
        else if (position.endsWith('right'))
            x = monitor.x + monitor.width - PANEL_W - MARGIN;
        else // center
            x = monitor.x + Math.round((monitor.width - PANEL_W) / 2);

        // Vertical - snap to shell bars so the panel feels native
        const topBarH = Main.layoutManager.panelBox?.height ?? 32;
        if (position.startsWith('top')) {
            y = monitor.y + topBarH + MARGIN;
        } else {
            // For bottom/center we need the actual rendered height, not MAX_H.
            // get_preferred_height(-1) returns [minH, naturalH] synchronously.
            const [, naturalH] = this._box.get_preferred_height(-1);
            const actualH = Math.min(naturalH, MAX_H);
            if (position.startsWith('bottom'))
                y = monitor.y + monitor.height - actualH - MARGIN;
            else // center
                y = monitor.y + Math.round((monitor.height - actualH) / 2);
        }

        this._box.set_position(Math.round(x), Math.round(y));

        // Overlay fills the screen for click-outside detection.
        this._overlay.set_position(monitor.x, monitor.y);
        this._overlay.set_size(monitor.width, monitor.height);
    }
}
