import { composeDataTable } from './data-table.js';
import { posPost } from '../lib/http.js';

/**
 * Alpine factory for the Products index page.
 *
 * Composes the shared data-table mixin (search · sort · paginate) with
 * Products-specific filter state — category pills, Active toggle,
 * Featured toggle, and a Table↔Grid view toggle.
 *
 * Filtering hooks into the mixin via `_dtFilter(row)`: the mixin runs
 * its built-in text search first, then calls our row-level predicate
 * to apply the active filters. Rows whose values come from
 * `data-dt-{col}` attributes so the JS doesn't need a JSON snapshot
 * of the catalog.
 */
export function productsPage() {
    return composeDataTable(
        {
            rowsSelector:       'tr[data-dt-row]',
            searchableSelector: '.prod-name, .mono',
            initialPageSize:    25,
        },
        {
            // ── Filter state ───────────────────────────────────────
            categoryFilter: 'all',   // 'all' | category id (as string)
            typeFilter:     'all',   // 'all' | 'simple' | 'variant' | 'kit'
            activeFilter:   'all',   // 'all' | 'active' | 'inactive'
            featuredFilter: 'all',   // 'all' | 'featured'

            // ── View toggle ────────────────────────────────────────
            view: 'grid',            // 'table' | 'grid' — grid is the default

            // ── Grid infinite scroll ───────────────────────────────
            // How many cards the grid reveals. Starts at 30; an
            // IntersectionObserver on the bottom sentinel bumps it by 30
            // as the user scrolls near the end. Reset to 30 whenever the
            // filtered set changes so a new search starts at the top.
            gridBatch: 30,
            gridLimit: 30,

            // ── Row-level quick actions ────────────────────────────
            // Set of product ids whose toggle button is currently
            // mid-flight. Used to disable the button so a fast double-
            // click doesn't race the AJAX.
            togglingIds: [],

            /**
             * Reactive per-row state. Keyed by product id; values are
             * `{ is_active, is_featured }`.
             *
             * Why this exists: the `data-dt-active` / `data-dt-featured`
             * DOM attributes aren't reactive — Alpine doesn't observe
             * `element.dataset` mutations. Bindings that read them
             * never re-render. Mutating this reactive object DOES
             * trigger re-renders.
             *
             * Populated from the rendered DOM in `init()` so the
             * server doesn't have to ship a separate JSON snapshot.
             */
            rowState: {},

            // Pager shows only in table view. In grid view we show every
            // matched product (no pagination) and lazy-render the cards on
            // scroll via CSS `content-visibility`, so the pager is hidden.
            get isTable() { return this.view === 'table'; },

            // Disable the mixin's client-side pagination in grid view — it
            // then reveals ALL matched cards at once (the browser only paints
            // the ones near the viewport). Table view keeps paging.
            get _dtPagingDisabled() { return this.view === 'grid'; },

            /** Convenience for the toolbar counter — total visible rows
             *  after every filter / search has been applied. */
            get visibleCount() { return this._dtMatched?.length ?? 0; },

            get hasActiveFilters() {
                return this.categoryFilter !== 'all'
                    || this.typeFilter     !== 'all'
                    || this.activeFilter   !== 'all'
                    || this.featuredFilter !== 'all'
                    || (this.search ?? '').trim() !== '';
            },

            resetFilters() {
                this.categoryFilter = 'all';
                this.typeFilter     = 'all';
                this.activeFilter   = 'all';
                this.featuredFilter = 'all';
                this.search         = '';
            },

            init() {
                // Grid is the default view on every screen (see `view`
                // above). The toggle still lets the user switch to the
                // horizontally-scrollable table when they want the dense
                // column layout.
                this.initDataTable();

                // Build the reactive rowState lookup from the rendered
                // DOM. Reads each table row's data-dt-* attrs once;
                // after this, every is_active / is_featured read goes
                // through the reactive map.
                this._dtRoot?.querySelectorAll('tr[data-dt-product-id]').forEach((row) => {
                    const id = parseInt(row.dataset.dtProductId, 10);
                    if (!Number.isFinite(id)) return;
                    this.rowState[id] = {
                        is_active:   row.dataset.dtActive === '1',
                        is_featured: row.dataset.dtFeatured === '1',
                    };
                });

                // Re-render whenever any filter / view state changes.
                // Reset page to 1 (table) and the grid reveal window to the
                // first batch so the user lands on the top of the new set.
                this.$watch('categoryFilter', () => { this.page = 1; this.gridLimit = this.gridBatch; this._dtRender(); });
                this.$watch('typeFilter',     () => { this.page = 1; this.gridLimit = this.gridBatch; this._dtRender(); });
                this.$watch('activeFilter',   () => { this.page = 1; this.gridLimit = this.gridBatch; this._dtRender(); });
                this.$watch('featuredFilter', () => { this.page = 1; this.gridLimit = this.gridBatch; this._dtRender(); });
                this.$watch('search',         () => { this.gridLimit = this.gridBatch; });

                // View toggle changes WHICH set of DOM rows the data-table
                // mixin operates on (table <tr>s vs grid cards). Re-scan
                // and re-render so pagination + filters apply to the
                // freshly-visible set.
                this.$watch('view', () => {
                    this.page = 1;
                    this.gridLimit = this.gridBatch;
                    this._dtCollect();
                    this._dtRender();
                });

                // Grid infinite scroll: reveal another batch when the
                // sentinel below the grid scrolls into view.
                this.$nextTick(() => this._initGridInfiniteScroll());
            },

            /** Observe the grid's bottom sentinel; grow the reveal window as
             *  it approaches the viewport. Falls back to showing everything
             *  when IntersectionObserver isn't available. */
            _initGridInfiniteScroll() {
                const sentinel = this.$refs?.gridSentinel;
                if (! sentinel) return;
                if (! ('IntersectionObserver' in window)) { this.gridLimit = Infinity; this._dtRender(); return; }

                this._gridObserver = new IntersectionObserver((entries) => {
                    if (entries.some((e) => e.isIntersecting)) this._growGrid();
                }, { rootMargin: '600px 0px' });
                this._gridObserver.observe(sentinel);
            },

            /** Reveal the next batch of grid cards, if any remain. */
            _growGrid() {
                if (this.view !== 'grid') return;
                const total = this._dtMatched?.length ?? 0;
                if (this.gridLimit >= total) return;

                this.gridLimit += this.gridBatch;
                this._dtRender();

                // The observer only fires on intersection *changes*. When the
                // newly revealed batch is short (e.g. 10-per-row = 3 rows) the
                // sentinel can stay in view and never re-trigger. Re-observing
                // forces a fresh check next frame, so we keep filling until the
                // sentinel is pushed past the viewport or everything is shown.
                const sentinel = this.$refs?.gridSentinel;
                if (this._gridObserver && sentinel) {
                    this._gridObserver.unobserve(sentinel);
                    this.$nextTick(() => {
                        if (this.$refs?.gridSentinel) this._gridObserver.observe(this.$refs.gridSentinel);
                    });
                }
            },

            /**
             * Override the mixin's row scanner so we pick `tr[data-dt-row]`
             * in table mode and `.prod-card[data-dt-row]` in grid mode.
             * Both views render the same row count; only one is visible
             * at a time (the other is x-show=false), so scanning the
             * wrong set would make pagination + sort produce zero
             * visible results.
             *
             * Also sets _dtBody so dtRefresh() can find the container to swap.
             */
            _dtCollect() {
                const sel = this.view === 'grid'
                    ? '.prod-card[data-dt-row]'
                    : 'tr[data-dt-row]';
                this._dtRows = Array.from(this._dtRoot.querySelectorAll(sel));
                this._dtBody = this._dtRows[0]?.parentElement ?? null;
                this._dtRows.forEach((row) => {
                    const target = row.querySelector('.prod-name, .prod-card-name');
                    row._dtText = (target?.textContent ?? '').trim().toLowerCase();
                });
            },

            /**
             * Products-specific refresh: swaps both the table <tbody> and
             * the grid container, then rebuilds `rowState` from the fresh
             * DOM attributes before re-collecting and re-rendering.
             *
             * The base mixin's dtRefresh() can't handle this page because:
             *  1. There are two separate row containers (tbody + .prod-grid).
             *  2. rowState is a reactive snapshot that must stay in sync with
             *     the server's latest is_active / is_featured values.
             */
            async dtRefresh() {
                if (this._dtRefreshing) return;
                this._dtRefreshing = true;
                try {
                    const html = await window.posGetHtml(window.location.href);
                    if (!html) { window.location.reload(); return; }

                    const doc = new DOMParser().parseFromString(html, 'text/html');

                    // Swap table body rows.
                    const localTbody = this._dtRoot.querySelector('tbody');
                    const freshTr    = doc.querySelector('tr[data-dt-row]');
                    if (localTbody && freshTr) {
                        localTbody.innerHTML = freshTr.parentElement.innerHTML;
                        if (window.Alpine?.initTree) window.Alpine.initTree(localTbody);
                    }

                    // Swap grid cards.
                    const localGrid  = this._dtRoot.querySelector('.prod-grid');
                    const freshCard  = doc.querySelector('.prod-card[data-dt-row]');
                    if (localGrid && freshCard) {
                        localGrid.innerHTML = freshCard.parentElement.innerHTML;
                        if (window.Alpine?.initTree) window.Alpine.initTree(localGrid);
                    }

                    // Rebuild rowState from the fresh DOM so is_active /
                    // is_featured badges and filter pills reflect server truth.
                    const freshState = {};
                    this._dtRoot.querySelectorAll('[data-dt-product-id]').forEach((row) => {
                        const id = parseInt(row.dataset.dtProductId, 10);
                        if (!Number.isFinite(id)) return;
                        freshState[id] = {
                            is_active:   row.dataset.dtActive   === '1',
                            is_featured: row.dataset.dtFeatured === '1',
                        };
                    });
                    this.rowState = freshState;

                    this._dtEverSorted = false;
                    this._dtCollect();
                    this._dtRender();
                } catch (_) {
                    window.location.reload();
                } finally {
                    this._dtRefreshing = false;
                }
            },

            /**
             * Recompute the summary cards from the matched (filtered,
             * pre-pagination) rows. Called by the mixin at the end of
             * every `_dtRender()`, so the cards track search + filters +
             * quick-toggles live. Reads is_active / is_featured from the
             * reactive `rowState` (kept in sync by `toggleField`), falling
             * back to the DOM attrs for any row not in the map.
             */
            _dtOnRender(matched) {
                // Grid reveal window. Paging is disabled in grid view, so the
                // mixin just made every matched card visible — re-hide the
                // ones past the current reveal limit so only `gridLimit` show
                // until the user scrolls (the sentinel observer grows it).
                if (this.view === 'grid' && Number.isFinite(this.gridLimit)) {
                    matched.forEach((row, i) => {
                        row.classList.toggle('dt-hidden', i >= this.gridLimit);
                    });
                }

                const root = this._dtRoot ?? this.$root;
                if (!root) return;

                let active = 0;
                let featured = 0;
                matched.forEach((row) => {
                    const id    = parseInt(row.dataset.dtProductId, 10);
                    const state = this.rowState[id];
                    const isActive   = state ? !!state.is_active   : row.dataset.dtActive   === '1';
                    const isFeatured = state ? !!state.is_featured : row.dataset.dtFeatured === '1';
                    if (isActive)   active++;
                    if (isFeatured) featured++;
                });

                const set = (key, val) => {
                    root.querySelectorAll(`[data-card-value="${key}"]`)
                        .forEach((el) => { el.textContent = String(val); });
                };
                set('total',    matched.length);
                set('active',   active);
                set('featured', featured);
            },

            /**
             * Row-level predicate called by the mixin's `_dtRender`
             * after the text-search filter. Returns true to keep a
             * row in the matched set.
             *
             * Reads `is_active` / `is_featured` from the reactive
             * `rowState` (not `dataset.dt*`) so the filter result
             * stays in sync with the quick-action toggles.
             */
            _dtFilter(row) {
                if (this.categoryFilter !== 'all') {
                    if (row.dataset.dtCategoryId !== String(this.categoryFilter)) return false;
                }
                if (this.typeFilter !== 'all') {
                    if (row.dataset.dtType !== this.typeFilter) return false;
                }
                const id    = parseInt(row.dataset.dtProductId, 10);
                const state = this.rowState[id];
                if (this.activeFilter === 'active'   && !state?.is_active) return false;
                if (this.activeFilter === 'inactive' && !!state?.is_active) return false;
                if (this.featuredFilter === 'featured' && !state?.is_featured) return false;
                return true;
            },

            /**
             * Margin %, computed client-side from data-dt-price /
             * data-dt-cost. Returns a string so we can append '%' and
             * apply color classes off the same value.
             */
            marginPct(row) {
                const price = parseFloat(row.dataset.dtPrice || '0');
                const cost  = parseFloat(row.dataset.dtCost  || '0');
                if (!price) return 0;
                return Math.round(((price - cost) / price) * 100);
            },

            /** Color class for the margin cell — matches the mockup's
             *  positive (≥35) / danger (<20) / neutral thresholds. */
            marginClass(row) {
                const m = this.marginPct(row);
                if (m < 20)  return 'prod-margin-danger';
                if (m >= 35) return 'prod-margin-positive';
                return '';
            },

            /**
             * Flip a single boolean flag on the product without
             * navigating to the editor. Optimistic — mutates the
             * reactive `rowState` so every binding (badge, button
             * pressed state, filter result) updates instantly. On
             * server failure it reverts and an error toast appears.
             *
             * `field` must be 'is_active' or 'is_featured' — the
             * server enforces the whitelist too.
             */
            async toggleField(id, field) {
                if (this.togglingIds.includes(id)) return;
                if (! this.rowState[id]) return;

                const current = !!this.rowState[id][field];
                const next    = !current;

                // Optimistic flip — Alpine reactivity propagates to
                // every template binding that reads rowState[id][field].
                this.rowState[id][field] = next;
                this.togglingIds = [...this.togglingIds, id];

                const url = `/admin/products/${id}/toggle`;

                let data;
                try {
                    ({ data } = await posPost(url, { field, value: next ? '1' : '0' }));
                } catch (e) {
                    this.rowState[id][field] = current;
                    this.togglingIds = this.togglingIds.filter((x) => x !== id);
                    if (e?.status === undefined) {
                        this.$store.toasts?.push({ type: 'error', message: 'Network error.' });
                    } else {
                        this.$store.toasts?.push({ type: 'error', message: e?.message || 'Could not update product.' });
                    }
                    return;
                }

                this.togglingIds = this.togglingIds.filter((x) => x !== id);

                // If the active or featured state changed, an active
                // filter pill may now exclude this row — re-render so
                // the row hides/shows in line with the new state.
                this._dtRender();

                if (data?.message) {
                    this.$store.toasts?.push({ type: 'success', message: data.message });
                }
            },
        },
    );
}
