assets/js/controllers/mobile-action-items-controller.js

import MobileControllerBase from "./mobile-controller-base.js"
import { MOBILE_QUEUE_DEFAULT_PER_PAGE } from "../mobile-queue-pagination.js"

/**
 * Mobile Action Items Controller
 *
 * Touch-optimized My To-Dos queue with expandable owner cards and inline
 * completion. This page is online-only because completion writes server state.
 */
class MobileActionItemsController extends MobileControllerBase {
    static targets = [
        "list", "loading", "empty", "error", "errorMessage",
        "countBadge", "refreshBtn", "loadMore", "loadMoreBtn",
        "status", "toast", "toastBody"
    ]

    static values = {
        dataUrl: String,
        completeUrl: String,
        perPage: { type: Number, default: MOBILE_QUEUE_DEFAULT_PER_PAGE },
    }

    initialize() {
        super.initialize()
        this._groups = []
        this._expandedKey = null
        this._submitting = false
        this._page = 1
        this._perPage = this._configuredPerPage()
        this._totalCount = 0
        this._hasNextPage = false
    }

    onConnect() {
        if (this.online) {
            this.loadItems()
        }
    }

    onConnectionStateChanged(isOnline) {
        if (isOnline && this._groups.length === 0) {
            this.loadItems()
        }
    }

    async refresh() {
        if (this.hasRefreshBtnTarget) {
            this.refreshBtnTarget.disabled = true
        }
        await this.loadItems()
        if (this.hasRefreshBtnTarget) {
            this.refreshBtnTarget.disabled = false
        }
    }

    async loadItems({ append = false } = {}) {
        if (!append) {
            this._page = 1
            this._groups = []
            this._expandedKey = null
            this._showState("loading")
        } else {
            this._setLoadMoreBusy(true)
        }

        try {
            const response = await this.fetchWithRetry(this._pageUrl(this._page))
            const data = await response.json()
            const pagination = data.pagination || {}
            this._totalCount = parseInt(pagination.total ?? data.openCount ?? this._openCountForGroups(data.groups || []), 10) || 0
            this._perPage = parseInt(pagination.perPage ?? this._perPage, 10) || this._perPage
            this._hasNextPage = pagination.hasNextPage === true
            this._page = parseInt(pagination.page ?? this._page, 10) || this._page
            this._groups = append
                ? this._mergeGroups(this._groups, data.groups || [])
                : data.groups || []

            if (this._openCount() === 0) {
                this._showState("empty")
            } else {
                this._showState("list")
                this._renderCards()
            }

            this._updateBadge()
            this._updateLoadMore()
            if (append) {
                this._announce(`Loaded ${this._openCount()} of ${this._totalCount} open to-dos.`)
            }
        } catch (error) {
            console.error("Failed to load to-dos:", error)
            if (this.hasErrorMessageTarget) {
                this.errorMessageTarget.textContent = error.message || "Failed to load to-dos."
            }
            this._showState("error")
        } finally {
            this._setLoadMoreBusy(false)
        }
    }

    async loadMore() {
        if (!this._hasNextPage) return
        this._page += 1
        await this.loadItems({ append: true })
    }

    toggleGroup(event) {
        const trigger = event.currentTarget
        const key = trigger.dataset.groupKey
        if (!key) return

        const card = this.listTarget.querySelector(`[data-group-key="${this._cssEscape(key)}"]`)
        if (!card) return

        if (this._expandedKey === key) {
            this._collapseCard(card)
            this._expandedKey = null
            return
        }

        if (this._expandedKey !== null) {
            const previous = this.listTarget.querySelector(`[data-group-key="${this._cssEscape(this._expandedKey)}"]`)
            if (previous) {
                this._collapseCard(previous)
            }
        }

        this._expandCard(card, key)
        this._expandedKey = key
    }

    async completeItem(event) {
        event.preventDefault()
        if (this._submitting) return

        if (!this.online) {
            this._showToast("You must be online to complete to-dos.", "danger")
            this._announce("You must be online to complete to-dos.")
            return
        }

        const button = event.currentTarget
        const itemId = parseInt(button.dataset.itemId || "0", 10)
        if (itemId <= 0) return

        const record = this._groups.flatMap(group => group.items || []).find(item => Number(item.id) === itemId)
        if (record?.isTerminal && !await window.KMP_accessibility.confirm(record.confirmation)) return
        this._submitting = true
        const item = button.closest("[data-item-id]")
        if (item) {
            item.classList.add("todo-item-completing")
        }
        button.disabled = true
        button.innerHTML = '<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>Completing...'

        try {
            const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ""
            const body = new FormData()
            body.append("_csrfToken", csrfToken)

            const response = await fetch(this._completeUrl(itemId), {
                method: "POST",
                headers: {
                    "Accept": "application/json",
                    "X-Requested-With": "XMLHttpRequest",
                    "X-CSRF-Token": csrfToken,
                },
                body,
            })
            const result = await this._readJsonResponse(response, "Failed to complete to-do. Please refresh and try again.")
            if (!response.ok || result.success === false) {
                throw new Error(result.error || `HTTP ${response.status}`)
            }

            this._removeItem(itemId)
            this._totalCount = Math.max(0, this._totalCount - 1)
            this._renderCards()
            this._updateBadge()
            this._updateLoadMore()
            const warning = typeof result.warning === "string" ? result.warning.trim() : ""
            const message = warning
                ? `To-do marked complete. Related to-dos need attention: ${warning}`
                : "To-do marked complete."
            this._showToast(message, warning ? "warning" : "success")
            this._announce(message)
            if (this._openCount() === 0 && this._hasNextPage) {
                await this.loadMore()
            } else if (this._openCount() === 0) {
                this._showState("empty")
            }
        } catch (error) {
            const message = error.message || "Failed to complete to-do."
            this._showToast(message, "danger")
            this._announce(message)
            if (item) {
                item.classList.remove("todo-item-completing")
            }
            button.disabled = false
            button.textContent = "Mark complete"
        } finally {
            this._submitting = false
        }
    }

    _renderCards() {
        if (!this.hasListTarget) return

        this.listTarget.innerHTML = this._groups.map(group => this._renderGroup(group)).join("")
    }

    _renderGroup(group) {
        const key = this._groupKey(group)
        const detailId = `todo-owner-detail-${this._escHtml(key)}`
        const count = Array.isArray(group.items) ? group.items.length : 0
        const meta = count === 1 ? "1 open to-do" : `${count} open to-dos`
        const sourceLink = group.url
            ? `<a href="${this._escHtml(group.url)}" class="btn btn-sm btn-outline-primary mb-3" data-turbo-frame="_top"><i class="bi bi-box-arrow-up-right me-1" aria-hidden="true"></i>View Source</a>`
            : ""
        const details = this._renderGroupDetails(group.details)

        return `<section class="card todo-owner-card" data-group-key="${this._escHtml(key)}" aria-labelledby="todo-owner-title-${this._escHtml(key)}">
            <button type="button"
                    class="todo-owner-summary"
                    data-action="click->mobile-action-items#toggleGroup"
                    data-group-key="${this._escHtml(key)}"
                    aria-expanded="false"
                    aria-controls="${detailId}">
                <span class="todo-owner-icon"><i class="bi bi-check2-all" aria-hidden="true"></i></span>
                <span class="todo-owner-info">
                    <span class="todo-owner-title d-block" id="todo-owner-title-${this._escHtml(key)}">${this._escHtml(group.label)}</span>
                    <span class="todo-owner-meta d-block">${meta}</span>
                </span>
                <span class="todo-owner-chevron"><i class="bi bi-chevron-right" aria-hidden="true"></i></span>
            </button>
            <div class="todo-owner-detail" id="${detailId}" hidden>
                ${details}
                ${sourceLink}
                ${(group.items || []).map(item => this._renderItem(item)).join("")}
            </div>
        </section>`
    }

    _renderGroupDetails(details) {
        if (!Array.isArray(details) || details.length === 0) return ""

        const rows = details.map(detail => {
            const value = this._escHtml(detail.value || "")
            const renderedValue = detail.url
                ? `<a href="${this._escHtml(detail.url)}" data-turbo-frame="_top">${value}</a>`
                : value

            return `
                <dt class="col-5 pe-2">${this._escHtml(detail.label || "")}</dt>
                <dd class="col-7 mb-1">${renderedValue}</dd>
            `
        }).join("")

        return `<dl class="row g-0 small mb-3">${rows}</dl>`
    }

    _renderItem(item) {
        const badge = item.isGating
            ? '<span class="badge bg-warning text-dark"><i class="bi bi-flag-fill me-1" aria-hidden="true"></i>Required</span>'
            : '<span class="badge bg-light text-dark border"><i class="bi bi-flag me-1" aria-hidden="true"></i>Optional</span>'
        const branch = item.branchName
            ? `<span class="badge bg-light text-dark border"><i class="bi bi-geo-alt me-1" aria-hidden="true"></i>${this._escHtml(item.branchName)}</span>`
            : ""

        return `<article class="todo-item" data-item-id="${item.id}">
            <div class="d-flex justify-content-between align-items-start gap-3">
                <div class="me-auto">
                    <h3 class="h6 mb-1">${this._escHtml(item.title)}</h3>
                    ${item.description ? `<p class="todo-item-description mb-2">${this._escHtml(item.description)}</p>` : ""}
                    <div class="d-flex flex-wrap gap-1">${badge}${branch}</div>
                </div>
                <button type="button"
                        class="btn btn-sm btn-success flex-shrink-0"
                        data-action="click->mobile-action-items#completeItem"
                        data-item-id="${item.id}"
                        aria-label="Mark complete: ${this._escHtml(item.title)}">
                    Mark complete
                </button>
            </div>
        </article>`
    }

    _expandCard(card, key) {
        card.classList.add("expanded")
        card.querySelector(".todo-owner-summary")?.setAttribute("aria-expanded", "true")
        const detail = card.querySelector(".todo-owner-detail")
        if (detail) {
            detail.hidden = false
        }
        this._announce(`Expanded ${card.querySelector(".todo-owner-title")?.textContent || "to-do group"}.`)
    }

    _collapseCard(card) {
        card.classList.remove("expanded")
        card.querySelector(".todo-owner-summary")?.setAttribute("aria-expanded", "false")
        const detail = card.querySelector(".todo-owner-detail")
        if (detail) {
            detail.hidden = true
        }
    }

    _removeItem(itemId) {
        this._groups = this._groups
            .map(group => ({
                ...group,
                items: (group.items || []).filter(item => item.id !== itemId),
            }))
            .filter(group => (group.items || []).length > 0)
    }

    async _readJsonResponse(response, fallbackMessage) {
        const contentType = response.headers.get("content-type") || ""
        if (!contentType.includes("application/json")) {
            throw new Error(fallbackMessage)
        }

        try {
            return await response.json()
        } catch (error) {
            throw new Error(fallbackMessage)
        }
    }

    _completeUrl(itemId) {
        return `${this.completeUrlValue.replace(/\/$/, "")}/${itemId}`
    }

    _groupKey(group) {
        return `${group.entityType || "entity"}-${group.entityId || "0"}`
    }

    _openCount() {
        return this._openCountForGroups(this._groups)
    }

    _openCountForGroups(groups) {
        return groups.reduce((count, group) => count + ((group.items || []).length), 0)
    }

    _showState(state) {
        if (this.hasLoadingTarget) this.loadingTarget.hidden = state !== "loading"
        if (this.hasEmptyTarget) this.emptyTarget.hidden = state !== "empty"
        if (this.hasErrorTarget) this.errorTarget.hidden = state !== "error"
        if (this.hasListTarget) this.listTarget.hidden = state !== "list"
        if (this.hasLoadMoreTarget) this.loadMoreTarget.hidden = state !== "list" || !this._hasNextPage
    }

    _updateBadge() {
        if (!this.hasCountBadgeTarget) return

        const loaded = this._openCount()
        const total = this._totalCount || loaded
        this.countBadgeTarget.textContent = loaded === total
            ? `${total} open`
            : `${loaded} of ${total} open`
        this.countBadgeTarget.hidden = total === 0
    }

    _updateLoadMore() {
        if (this.hasLoadMoreTarget) {
            this.loadMoreTarget.hidden = !this._hasNextPage || this._openCount() === 0
        }
        if (this.hasLoadMoreBtnTarget) {
            this.loadMoreBtnTarget.disabled = !this._hasNextPage
            const remaining = Math.max(0, this._totalCount - this._openCount())
            this.loadMoreBtnTarget.textContent = remaining > 0
                ? `Load more (${remaining} remaining)`
                : "Load more"
        }
    }

    _setLoadMoreBusy(isBusy) {
        if (!this.hasLoadMoreBtnTarget) return
        this.loadMoreBtnTarget.disabled = isBusy || !this._hasNextPage
        this.loadMoreBtnTarget.setAttribute("aria-busy", isBusy ? "true" : "false")
        if (isBusy) {
            this.loadMoreBtnTarget.innerHTML = '<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>Loading...'
        } else {
            this._updateLoadMore()
        }
    }

    _mergeGroups(existing, incoming) {
        const merged = existing.map(group => ({
            ...group,
            items: [...(group.items || [])],
        }))
        const groupIndex = new Map(merged.map((group, index) => [this._groupKey(group), index]))
        incoming.forEach(group => {
            const key = this._groupKey(group)
            if (!groupIndex.has(key)) {
                groupIndex.set(key, merged.length)
                merged.push({
                    ...group,
                    items: [...(group.items || [])],
                })
                return
            }
            const target = merged[groupIndex.get(key)]
            const seen = new Set((target.items || []).map(item => item.id))
            ;(group.items || []).forEach(item => {
                if (!seen.has(item.id)) {
                    target.items.push(item)
                    seen.add(item.id)
                }
            })
            target.openCount = target.items.length
        })

        return merged
    }

    _pageUrl(page) {
        const url = new URL(this.dataUrlValue, window.location.origin)
        url.searchParams.set("page", page)
        url.searchParams.set("per_page", this._perPage)
        return url.toString()
    }

    _configuredPerPage() {
        const perPage = parseInt(this.perPageValue, 10)
        return perPage > 0 ? perPage : MOBILE_QUEUE_DEFAULT_PER_PAGE
    }

    _showToast(message, type) {
        if (this.hasToastTarget && this.hasToastBodyTarget && window.bootstrap?.Toast) {
            this.toastBodyTarget.textContent = message
            this.toastTarget.className = `toast align-items-center border-0 text-bg-${type}`
            new window.bootstrap.Toast(this.toastTarget).show()
        }
    }

    _announce(message) {
        if (this.hasStatusTarget) {
            this.statusTarget.textContent = message
        }
        window.KMP_accessibility?.announce?.(message)
    }

    _cssEscape(value) {
        if (window.CSS?.escape) {
            return window.CSS.escape(value)
        }

        return String(value).replace(/["\\]/g, "\\$&")
    }

    _escHtml(str) {
        if (str === null || str === undefined) return ""
        const div = document.createElement("div")
        div.textContent = String(str)
        return div.innerHTML
    }
}

if (!window.Controllers) {
    window.Controllers = {}
}
window.Controllers["mobile-action-items"] = MobileActionItemsController

export default MobileActionItemsController