'use strict';

var index = require('./index-C_OnxXhP.js');
var index_esm = require('./index.esm-C1i_NocP.js');

function isIconList(item) {
    return 'type' in item && item.type === 'iconList';
}
function getProcessedItemTimestamp(item) {
    if (item.type === 'chat' || item.type === 'iconList') {
        return item.item.timestamp;
    }
    return undefined;
}
/**
 * Processes a list of chat list inputs by adding date headers and a "new items" indicator.
 *
 * @param items - The list of items to process. Pre-sorted by `timestamp` in ascending order.
 * @param dateTimeFormatter - A service used to format dates for the date headers.
 * @param lastVisitedTimestamp - The timestamp of the user's last visit. Used to position the
 *   "new items" indicator.
 *
 * @returns An array of processed items, including chat items, icon lists, date headers,
 *   and a "new items" indicator if applicable.
 */
function processChatItems(items, dateTimeFormatter, lastVisitedTimestamp) {
    const initialContext = {
        items: items,
        processedItems: [],
        lastVisitedTimestamp: lastVisitedTimestamp,
        dateTimeFormatter: dateTimeFormatter,
    };
    // Add processors here, in the order they should be executed.
    // `insertNewItemIndicator` runs after `insertDateGroups` so the
    // indicator can be placed at top level when the seen/unseen
    // boundary falls between two groups, and only inside a group when
    // the boundary falls within a single day. That placement makes
    // `reverseProcessedItems` a pure two-level reverse — no group-
    // boundary hoisting needed.
    const processors = [
        insertNotifications,
        insertDateGroups,
        insertNewItemIndicator,
    ];
    const finalContext = createPipeline(initialContext, processors);
    return finalContext.processedItems;
}
function createPipeline(initialContext, processors) {
    // eslint-disable-next-line unicorn/no-array-reduce
    return processors.reduce((context, processor) => processor(context), initialContext);
}
/**
 * Reverses the chronological order of a processed-items array.
 *
 * Pure two-level reverse: the top-level array is reversed, and each
 * group's items are reversed. `insertNewItemIndicator` places the
 * indicator at top level when the seen/unseen boundary falls
 * between groups, and only nests it inside a group when the
 * boundary falls within a single day — so reversal never needs to
 * move an indicator across levels to preserve its separator role.
 *
 * Used by `limebb-chat-list` when rendering in `newest-on-top`
 * layout, so that DOM order continues to match visual order
 * (newest-first in that layout), keeping copy/paste, screen-reader,
 * and tab order aligned with what the user sees.
 *
 * @param items - processed items in ascending-by-timestamp order
 * @returns processed items in descending-by-timestamp order
 */
function reverseProcessedItems(items) {
    return items.toReversed().map((item) => {
        if (item.type !== 'group') {
            return item;
        }
        return Object.assign(Object.assign({}, item), { items: item.items.toReversed() });
    });
}
/**
 *
 * @param context
 */
function insertNotifications(context) {
    for (const item of context.items) {
        if (isIconList(item)) {
            context.processedItems.push({ type: 'iconList', item: item });
        }
        else {
            context.processedItems.push({ type: 'chat', item: item });
        }
    }
    return context;
}
/**
 *
 * @param context
 */
function insertNewItemIndicator(context) {
    if (!context.lastVisitedTimestamp) {
        return context;
    }
    const groups = context.processedItems;
    const lastVisited = context.lastVisitedTimestamp;
    // Walk groups newest → oldest. Items and groups are sorted
    // ascending, so at most one group straddles the seen/unseen
    // boundary; the rest are entirely seen or entirely unseen. In an
    // active chat where the newest item is already seen, the loop
    // exits in O(1).
    for (let g = groups.length - 1; g >= 0; g--) {
        const group = groups[g];
        if (group.type !== 'group') {
            continue;
        }
        const seenIndex = findNewestSeenIndex(group.items, lastVisited);
        if (seenIndex === -1) {
            continue;
        }
        return placeIndicatorAtBoundary(context, groups, g, group, seenIndex);
    }
    // No seen item found — everything is unseen, no boundary to mark.
    return context;
}
function findNewestSeenIndex(items, lastVisited) {
    for (let i = items.length - 1; i >= 0; i--) {
        const timestamp = getProcessedItemTimestamp(items[i]);
        if (timestamp === undefined) {
            continue;
        }
        if (new Date(timestamp) <= lastVisited) {
            return i;
        }
    }
    return -1;
}
function placeIndicatorAtBoundary(context, groups, groupIndex, group, seenIndex) {
    const isLastItemInGroup = seenIndex === group.items.length - 1;
    const isLastGroup = groupIndex === groups.length - 1;
    // Newest seen item is the very last item overall → everything is
    // seen, nothing to mark.
    if (isLastItemInGroup && isLastGroup) {
        return context;
    }
    // Seen item is last in its group → boundary is between groups.
    // Place the indicator at top level so reversal stays a pure
    // two-level reverse.
    if (isLastItemInGroup) {
        return Object.assign(Object.assign({}, context), { processedItems: [
                ...groups.slice(0, groupIndex + 1),
                { type: 'newItemIndicator' },
                ...groups.slice(groupIndex + 1),
            ] });
    }
    // Boundary falls inside a single day — indicator stays nested.
    const newGroupItems = [
        ...group.items.slice(0, seenIndex + 1),
        { type: 'newItemIndicator' },
        ...group.items.slice(seenIndex + 1),
    ];
    return Object.assign(Object.assign({}, context), { processedItems: [
            ...groups.slice(0, groupIndex),
            Object.assign(Object.assign({}, group), { items: newGroupItems }),
            ...groups.slice(groupIndex + 1),
        ] });
}
/**
 *
 * @param context
 */
function insertDateGroups(context) {
    const newProcessedItems = [];
    let currentGroup = null;
    let lastProcessedDate = null;
    for (const item of context.processedItems) {
        if (item.type !== 'chat' && item.type !== 'iconList') {
            continue;
        }
        const currentDate = context.dateTimeFormatter.format(new Date(item.item.timestamp), 'date');
        if (currentDate !== lastProcessedDate) {
            if (currentGroup) {
                newProcessedItems.push(currentGroup);
            }
            currentGroup = {
                type: 'group',
                groupType: 'dateGroup',
                heading: currentDate,
                items: [item],
            };
            lastProcessedDate = currentDate;
            continue;
        }
        currentGroup === null || currentGroup === void 0 ? void 0 : currentGroup.items.push(item);
    }
    if (currentGroup) {
        newProcessedItems.push(currentGroup);
    }
    return Object.assign(Object.assign({}, context), { processedItems: newProcessedItems });
}

// "Near enough the newest edge to count as sticky." 50px is roughly
// two message-bubble margins; a user who is within that distance
// clearly wasn't scrolling back through history, so we treat their
// position as "stuck to newest" and re-pin on each items update.
const NEAR_NEWEST_THRESHOLD_PX = 50;
/**
 * Keeps a scroll container stuck to its "newest" edge as items are
 * appended, so long as the user hasn't scrolled away.
 *
 * We use imperative scrolling rather than `flex-direction: column-reverse`
 * so DOM order matches visual order (copy/paste, screen readers, tab
 * order all align with what the user sees). CSS `overflow-anchor: auto`
 * — the default and left enabled here — solves a different problem:
 * preserving scroll position when content is inserted *above* the
 * viewport. It doesn't stick the scroll to the newest edge when items
 * arrive below the viewport, which is what chat typically needs; that
 * requires the imperative approach in this class.
 *
 * Lifecycle:
 * - `beforeRender()` in `componentWillRender` — snapshot whether the
 *   user is near the newest edge, using the order the scroll position
 *   was last measured against. Capturing here (synchronously, before
 *   the new DOM lands) avoids the race a scroll-event listener would
 *   have with `componentDidRender`.
 * - `afterRender()` in `componentDidRender` — if the snapshot said
 *   "near newest", re-pin to the newest edge (new `scrollHeight`).
 * - `repinIfSticky()` for out-of-band re-pins (e.g. after the typing
 *   indicator's height transition finishes — its `scrollHeight`
 *   change lands after `componentDidRender`).
 *
 * Scroll-container contract: the passed element must itself be the
 * scrolling ancestor (the element whose `scrollTop` / `scrollHeight`
 * / `clientHeight` reflect the chat list's scroll state). In practice
 * that is the component's host, which sets `overflow: hidden auto` on
 * itself. If future refactors introduce an inner scrolling wrapper,
 * pass that wrapper instead.
 */
class ScrollAnchor {
    constructor(scrollContainer, getOrder, initialOrder) {
        this.scrollContainer = scrollContainer;
        this.getOrder = getOrder;
        this.isSticky = true;
        this.paintedOrder = initialOrder;
    }
    beforeRender() {
        if (!this.scrollContainer.isConnected) {
            return;
        }
        // Measure against the order the scroll position was last painted
        // with. If `order` just changed, the scroll position still refers
        // to the previous layout until this render commits.
        this.isSticky =
            this.distanceFromNewestEdge(this.paintedOrder) <
                NEAR_NEWEST_THRESHOLD_PX;
    }
    afterRender() {
        if (!this.scrollContainer.isConnected) {
            return;
        }
        this.paintedOrder = this.getOrder();
        if (this.isSticky) {
            this.scrollToNewest();
        }
    }
    repinIfSticky() {
        if (!this.scrollContainer.isConnected) {
            return;
        }
        if (this.isSticky) {
            this.scrollToNewest();
        }
    }
    scrollToNewest() {
        const target = this.getOrder() === 'oldest-on-top'
            ? this.scrollContainer.scrollHeight
            : 0;
        // Reading `scrollTop` is a cheap (already-measured) lookup, and
        // short-circuiting avoids forcing a layout when we'd write the
        // same value — important under high-frequency re-renders
        // (streaming AI output).
        if (this.scrollContainer.scrollTop === target) {
            return;
        }
        this.scrollContainer.scrollTop = target;
    }
    distanceFromNewestEdge(order) {
        return order === 'oldest-on-top'
            ? this.scrollContainer.scrollHeight -
                this.scrollContainer.scrollTop -
                this.scrollContainer.clientHeight
            : this.scrollContainer.scrollTop;
    }
}

// The CSS property the typing-indicator container transitions.
// Mirrored from chat-list.scss `.typing-indicator-height-animation
// { transition: grid-template-rows ... }`. The runtime verifier below
// guards against the SCSS and the handler-side filter silently
// disagreeing.
const HEIGHT_ANIMATION_PROPERTY = 'grid-template-rows';
/**
 * Returns a `transitionend` listener for the typing indicator's
 * height transition. Filters by property name so unrelated
 * transitions on the same element (e.g. opacity) don't trigger the
 * callback.
 * @param onHeightTransitionEnd
 */
function createHeightTransitionEndHandler(onHeightTransitionEnd) {
    return (event) => {
        if (event.propertyName !== HEIGHT_ANIMATION_PROPERTY) {
            return;
        }
        onHeightTransitionEnd();
    };
}
/**
 * Verifies that the passed element actually transitions on the
 * property the height-transition handler filters on. Surfaces SCSS
 * drift loudly instead of silently breaking auto-scroll-on-indicator-
 * appearance.
 * @param element
 */
function verifyHeightAnimationProperty(element) {
    if (!element) {
        return;
    }
    const transitionProperty = globalThis.getComputedStyle(element).transitionProperty;
    if (!transitionProperty) {
        return;
    }
    const properties = new Set(transitionProperty.split(',').map((p) => p.trim()));
    if (!properties.has('all') && !properties.has(HEIGHT_ANIMATION_PROPERTY)) {
        console.warn(`[limebb-chat-list] Typing indicator is transitioning \`${transitionProperty}\` instead of \`${HEIGHT_ANIMATION_PROPERTY}\`. The auto-scroll on indicator appearance will not fire. Update the CSS or the handler so they match.`);
    }
}

const chatListCss = () => `@charset "UTF-8";:host(limebb-chat-list){--limel-top-edge-fade-height:0.75rem;isolation:isolate;--limel-overflow-mask-vertical:linear-gradient(       to bottom,       transparent 0%,       black calc(0% + var(--limel-top-edge-fade-height, 1rem)),       black calc(100% - var(--limel-bottom-edge-fade-height, 1rem)),       transparent 100%   );-webkit-mask-image:var(--limel-overflow-mask-vertical);mask-image:var(--limel-overflow-mask-vertical);padding-top:var(--limel-top-edge-fade-height, 1rem);padding-bottom:var(--limel-bottom-edge-fade-height, 1rem);box-sizing:border-box;display:flex;flex-direction:column;gap:0.5rem;height:100%;width:100%;min-width:0;min-height:0;padding:var(--limel-top-edge-fade-height) 0.5rem;overflow:hidden auto}:host(limebb-chat-list.is-empty){padding:0}*{box-sizing:border-box}limel-spinner{align-self:center;justify-self:center;margin:auto}.date-group{isolation:isolate;display:flex;flex-direction:inherit;gap:0.5rem}.date-heading{position:sticky;z-index:1;top:0.25rem;display:flex;border-radius:9rem;padding:0.25rem 0.5rem;margin:0 auto;width:fit-content;font-size:0.75rem;line-height:1;color:rgb(var(--contrast-900));backdrop-filter:blur(0.5rem);-webkit-backdrop-filter:blur(0.5rem);transition:color 0.2s ease, border-color 0.4s ease}.date-heading:hover{color:rgb(var(--contrast-1000));border-color:rgb(var(--contrast-500))}.date-heading:hover:before{opacity:1}.date-heading:before{transition:opacity 0.2s ease;content:"";position:absolute;z-index:-1;inset:0;opacity:0.6;border-radius:inherit;background-color:rgb(var(--contrast-100))}.new-items-indicator{position:relative;isolation:isolate;display:flex;align-items:center;justify-content:flex-end;margin:0.25rem 0}.new-items-indicator hr{border:none;position:absolute;width:100%;height:1px;background-color:rgb(var(--color-red-lighter))}.new-items-indicator h3{z-index:1;padding:0 0.5rem;border-radius:1rem;margin:0 1rem;font-size:small;line-height:0.75rem;text-transform:lowercase;color:rgb(var(--color-red-default));background-color:rgb(var(--contrast-100))}.typing-indicator-height-animation{--limebb-chat-typing-opacity-transition-speed:0.1s;--limebb-chat-typing-opacity-transition-delay:0s;--limebb-chat-typing-grid-template-rows-transition-speed:0.3s;position:sticky;z-index:1;transition:grid-template-rows var(--limebb-chat-typing-grid-template-rows-transition-speed) ease;display:grid;grid-template-rows:0}.typing-indicator-height-animation div{transition:opacity var(--limebb-chat-typing-opacity-transition-speed) ease var(--limebb-chat-typing-opacity-transition-delay);overflow:hidden;opacity:0}:host(limebb-chat-list[order=oldest-on-top]) .typing-indicator-height-animation{bottom:0}:host(limebb-chat-list[order=newest-on-top]) .typing-indicator-height-animation{top:0}:host(limebb-chat-list[is-typing-indicator-visible]) .typing-indicator-height-animation{--limebb-chat-typing-opacity-transition-speed:0.4s;--limebb-chat-typing-opacity-transition-delay:0.3s;--limebb-chat-typing-grid-template-rows-transition-speed:0.46s;grid-template-rows:1.5rem}:host(limebb-chat-list[is-typing-indicator-visible]) .typing-indicator-height-animation div{opacity:1}.date-group limebb-chat-item{--limebb-promoted-action-bar-grid-template-rows:0fr}:host(limebb-chat-list[order=oldest-on-top]) .date-group:last-of-type limebb-chat-item:last-of-type{--limebb-promoted-action-bar-grid-template-rows:1fr}:host(limebb-chat-list[order=newest-on-top]) .date-group:first-of-type limebb-chat-item:first-of-type{--limebb-promoted-action-bar-grid-template-rows:1fr}`;

const ChatList = class {
    constructor(hostRef) {
        index.registerInstance(this, hostRef);
        /**
         * List of items to display in the feed.
         * These items must be sorted by their `timestamp` in ascending order
         * (oldest first, newest last). This matches the natural visual order
         * for the default `oldest-on-top` layout, keeping DOM order aligned
         * with visual order for copy/paste, screen readers, and tab order.
         *
         * See the [ChatListItem](#/type/ChatListItem/) type for the kinds of
         * items that can appear in the list.
         */
        this.items = [];
        /**
         * - `newest-on-top` displays new messages at the top of the list;
         * used if the chat input is located on top of the chat interface.
         * - `oldest-on-top` displays new messages at the bottom of the list (default);
         * used if the chat input is located at the bottom of the chat interface.
         *
         * :::important
         * Using this prop will _not_ automatically sort the chat items for you!
         * You still need to pass the items in the correct order (ascending order by timestamp — oldest first).
         * This prop only affects the direction of the elements in the user interface,
         * such as the location of the typing indicator or the scrollbars.
         * :::
         */
        this.order = 'oldest-on-top';
        this.processedItems = [];
        this.handleHeightTransitionEnd = createHeightTransitionEndHandler(() => { var _a; return (_a = this.scrollAnchor) === null || _a === void 0 ? void 0 : _a.repinIfSticky(); });
    }
    handleItemsChange() {
        let lastVisited = null;
        if (this.lastVisitedTimestamp) {
            lastVisited = parseToUTCDate(this.lastVisitedTimestamp);
        }
        const processed = processChatItems(this.items, this.dateTimeFormatter, lastVisited);
        this.processedItems =
            this.order === 'newest-on-top'
                ? reverseProcessedItems(processed)
                : processed;
    }
    componentWillLoad() {
        this.handleItemsChange();
        this.scrollAnchor = new ScrollAnchor(this.host, () => this.order, this.order);
    }
    componentDidLoad() {
        var _a;
        verifyHeightAnimationProperty((_a = this.host.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.typing-indicator-height-animation'));
    }
    componentWillRender() {
        if (this.items.length === 0) {
            // Nothing to measure or scroll against. `isSticky` retains
            // its last value (default `true`), so the first non-empty
            // render still pins correctly.
            return;
        }
        this.scrollAnchor.beforeRender();
    }
    componentDidRender() {
        if (this.items.length === 0) {
            return;
        }
        this.scrollAnchor.afterRender();
    }
    render() {
        return (index.h(index.Host, { key: '1684ed7ba9da966e3c8b8c1c9615738b825b28fa', class: {
                'is-empty': this.items.length === 0 &&
                    !this.isTypingIndicatorVisible,
            }, "aria-busy": this.isBusy(), role: "log" }, this.renderSpinner(), this.order === 'newest-on-top' &&
            this.renderTypingIndicatorBlock(), this.renderChatItems(), this.order === 'oldest-on-top' &&
            this.renderTypingIndicatorBlock()));
    }
    renderSpinner() {
        if (!this.loading) {
            return;
        }
        return index.h("limel-spinner", null);
    }
    renderTypingIndicatorBlock() {
        // The indicator's grid-template-rows transition grows the
        // container over ~0.46s. `componentDidRender` fires before the
        // transition starts, so `scrollHeight` at that moment doesn't
        // include the final height. `handleHeightTransitionEnd` re-pins
        // once the transition lands.
        return (index.h("div", { class: "typing-indicator-height-animation", onTransitionEnd: this.handleHeightTransitionEnd }, index.h("div", null, this.renderTypingIndicator())));
    }
    renderTypingIndicator() {
        if (!this.isTypingIndicatorVisible) {
            return;
        }
        return index.h("limebb-typing-indicator", null);
    }
    renderChatItems() {
        return this.processedItems.map((item) => {
            switch (item.type) {
                case 'group': {
                    return this.renderGroup(item);
                }
                case 'newItemIndicator': {
                    return this.renderIndicator();
                }
                case 'chat':
                case 'iconList': {
                    // `insertDateGroups` wraps every `chat` / `iconList`
                    // in a `group`, so these variants shouldn't appear
                    // at top level. If the processor pipeline is ever
                    // reordered, render nothing rather than double-
                    // render once the wrapping group includes them.
                    return null;
                }
                default: {
                    return assertNever(item);
                }
            }
        });
    }
    renderGroup(group) {
        const chatItems = group.items.map((item) => {
            switch (item.type) {
                case 'chat': {
                    return this.renderChatItem(item.item);
                }
                case 'iconList': {
                    return this.renderIconList(item.item);
                }
                case 'newItemIndicator': {
                    return this.renderIndicator();
                }
                case 'group': {
                    // Date groups don't nest. A compile-time `never`
                    // here would require a separate nested-item type;
                    // this runtime null return is the pragmatic
                    // middle ground.
                    return null;
                }
                default: {
                    return assertNever(item);
                }
            }
        });
        // Key the section by the group's heading (unique per date, and
        // stable across item additions). Must not vary with group
        // content: if it did, appending a new message would change the
        // key and cause Stencil to remount the whole section, briefly
        // collapsing scrollHeight and scrolling the list to the top
        // before `componentDidRender` scrolls it back.
        const sectionKey = 'date-group-' + group.heading;
        const headingId = `heading-${sectionKey}`.replaceAll(/[^a-zA-Z0-9_-]/g, '_');
        const dateHeading = (index.h("a", { id: headingId, class: "date-heading", role: "heading", "aria-level": 2 }, group.heading));
        return (index.h("section", { class: "date-group", key: sectionKey, "aria-labelledby": headingId }, dateHeading, chatItems));
    }
    renderChatItem(item) {
        return (index.h("limebb-chat-item", { platform: this.platform, context: this.context, item: item, helperText: item.helperText, hasError: item.hasError, key: item.id }));
    }
    renderIconList(item) {
        return index.h("limebb-chat-icon-list", { item: item, key: item.id });
    }
    renderIndicator() {
        return (index.h("div", { class: "new-items-indicator", role: "separator" }, index.h("hr", null), index.h("h3", null, this.translator.get('webclient.new'))));
    }
    isBusy() {
        return this.loading || this.isTypingIndicatorVisible ? 'true' : 'false';
    }
    get dateTimeFormatter() {
        return this.platform.get(index_esm.n.DateTimeFormatter);
    }
    get translator() {
        return this.platform.get(index_esm.n.Translate);
    }
    get host() { return index.getElement(this); }
    static get watchers() { return {
        "items": [{
                "handleItemsChange": 0
            }],
        "order": [{
                "handleItemsChange": 0
            }]
    }; }
};
function parseToUTCDate(date) {
    if (typeof date === 'string') {
        return new Date(Date.parse(date));
    }
    return new Date(date); // This will automatically be UTC
}
function assertNever(value) {
    throw new Error(`Unexpected value in exhaustive switch: ${JSON.stringify(value)}`);
}
ChatList.style = chatListCss();

exports.limebb_chat_list = ChatList;
