import { Link, router, useHttp } from '@inertiajs/react'
import { createPortal } from 'react-dom'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as Icons from '@/Components/Icons'
import { useToast } from '@/Components/Toast'

const { Bell } = Icons

const PANEL_WIDTH = 340

/**
 * How often the unread COUNT refreshes on its own, in ms. 60s — user decision 2026-08-10.
 *
 * Measured before choosing this: the count is one indexed query averaging 8 ms, against 105
 * active users. Even with every one of them online this is ~1.75 queries/second — about 1.4%
 * of a core. The "polling is expensive" instinct does not survive contact with the numbers at
 * this app's size.
 *
 * What IS unaffordable here is a persistent connection. SSE pins one PHP worker per open
 * connection and the worker pool is small — twenty users online would starve the app. Real
 * websockets (Reverb) need a second long-running daemon, and this repo has no supervised
 * process at all: GH #155 is the story of the queue worker being absent for twelve days with
 * nobody noticing. A second unmonitored daemon repeats that failure mode.
 *
 * ⚠️ The interval is the SLOWEST of the four things that refresh this number, not the only
 * one — see `refreshCount` below. Lengthening it from 45s to 60s therefore did not make the
 * badge any staler in practice; mount, tab-focus and every completed visit still resync it.
 *
 * Only the COUNT is polled. The list is still fetched on open, so a user who never opens the
 * bell costs one small query per interval and nothing else.
 */
const POLL_MS = 60_000

/**
 * Header bell + notification dropdown (GH #322).
 *
 * Until 2026-08-09 this was an indicator only — a `<span role="status">` with no click
 * handler, because "a control that looks clickable but does nothing is worse than a plain
 * mark" (user decision 2026-08-07). There is now something to open, so it is a real button.
 *
 * ─── WHY `position: fixed` IN A PORTAL, NOT `absolute` ───
 * The sidebar's nav container is `overflow-y-auto overflow-x-hidden` and the `<aside>` is
 * `sticky … z-30`. An absolutely-positioned panel anchored inside it gets CLIPPED and simply
 * never appears. This is the same reason the sidebar's own tooltips portal to `document.body`
 * with fixed coordinates; do not "simplify" it back to absolute.
 *
 * ─── WHY CLICK AND NOT HOVER ───
 * The history popovers in this app open on hover, which suits a preview you glance at. An
 * inbox is read, not glanced at, and it sits next to the collapse toggle — a hover-open panel
 * there would ambush anyone reaching for that button.
 *
 * ─── THE LIST IS UNREAD-ONLY ───
 * User decision 2026-08-10, reversing the "New"/"Earlier" two-group shape this shipped with.
 * Reading an item removes it from the list, so the badge and the list can never tell different
 * stories: no badge means an empty dropdown, and clearing the badge clears the dropdown. The
 * rows are not deleted — `IsRead`/`ReadAt` keep the history in the table.
 */
export function NotificationBell({ count, collapsed, onNavigate }) {
    const [pos, setPos] = useState(null) // null = closed
    const [items, setItems] = useState(null) // null = not loaded yet
    const [loading, setLoading] = useState(false)
    const [failed, setFailed] = useState(false)
    // The live number, from the poll or the feed. Null = nothing fresher than the page prop.
    const [unread, setUnread] = useState(null)

    const wrapRef = useRef(null)
    const panelRef = useRef(null)
    const triggerRef = useRef(null)
    const reqRef = useRef(0)
    const countReqRef = useRef(0)
    const http = useHttp({})
    // A second client for the poll: sharing one would let a background tick cancel the
    // in-flight feed request the user is actually waiting on.
    const pollHttp = useHttp({})
    // Held in a ref so `refreshCount` can stay identity-stable (the effect below registers a
    // router listener with it) while still calling through the CURRENT client, not the one
    // captured at mount.
    const pollHttpRef = useRef(pollHttp)
    pollHttpRef.current = pollHttp
    const { show: showToast } = useToast()

    const open = pos !== null
    /*
     * `bellCount` is DEFERRED, so it is genuinely absent for one round trip after every full
     * visit. Distinguishing "not here yet" (undefined) from "the server says zero" matters:
     * coercing both to 0 made the badge blink to nothing on every navigation and then come
     * back, which reads exactly like a number that cannot be trusted until you click it.
     */
    const serverCount = typeof count === 'number' ? count : null
    // The freshest number wins. `unread` is set by the poll, by the feed, and by mark-all-read;
    // it stands down as soon as a REAL server number arrives, so a stale local value can never
    // outlive the next sync.
    const shown = unread ?? serverCount ?? 0

    const close = useCallback(({ restoreFocus = false } = {}) => {
        setPos(null)
        // Dismissing with Escape must put focus back on the trigger, or a keyboard user is
        // dropped at the top of the document with no idea where they were.
        if (restoreFocus) triggerRef.current?.focus()
    }, [])

    useEffect(() => {
        if (!open) return undefined

        const onDown = (e) => {
            if (wrapRef.current?.contains(e.target)) return
            if (panelRef.current?.contains(e.target)) return
            close()
        }
        const onKey = (e) => {
            if (e.key === 'Escape') close({ restoreFocus: true })
        }
        // A fixed panel does not travel with the page, so anything that moves the trigger
        // must dismiss it rather than leave it stranded beside nothing.
        //
        // ⚠️ Capture-phase 'scroll' also fires for the panel's OWN overflow container, so this
        // has to ignore events originating inside the panel — otherwise the dropdown closes
        // the instant you scroll the list you just opened.
        const onScroll = (e) => {
            if (panelRef.current?.contains(e.target)) return
            close()
        }
        const onResize = () => close()

        document.addEventListener('mousedown', onDown)
        document.addEventListener('keydown', onKey)
        window.addEventListener('resize', onResize)
        window.addEventListener('scroll', onScroll, true)

        // role="dialog" promises focus goes with it. Without this the panel is announced and
        // then never reached by the Tab order.
        panelRef.current?.focus()

        return () => {
            document.removeEventListener('mousedown', onDown)
            document.removeEventListener('keydown', onKey)
            window.removeEventListener('resize', onResize)
            window.removeEventListener('scroll', onScroll, true)
        }
    }, [open, close])

    // A REAL server number supersedes the local override. Guarded on `serverCount` rather than
    // on `count`, so the deferred prop's absent phase does not wipe a freshly polled value.
    useEffect(() => {
        if (serverCount !== null) setUnread(null)
    }, [serverCount])

    /**
     * Ask the server for the number. Cheap by construction: one indexed count, 12 bytes back.
     *
     * ⚠️ This deliberately does NOT use `router.reload({ only: ['bellCount'] })`. That was the
     * first attempt and it was measured at 195 ms / 21 queries per poll: an Inertia partial
     * reload still runs the page's controller, so polling from a list page re-ran that list's
     * whole query set on a timer. A dedicated JSON route costs one query and re-renders nothing
     * outside this component.
     */
    const refreshCount = useCallback(() => {
        // A backgrounded tab is the common case; without this it would poll all night.
        if (document.visibilityState !== 'visible') return

        const token = ++countReqRef.current

        pollHttpRef.current.get(route('notifications.count'), {
            onSuccess: (data) => {
                // A slow earlier answer must never overwrite a newer one.
                if (token === countReqRef.current && typeof data?.unread === 'number') setUnread(data.unread)
            },
            // A failed poll is not worth telling anyone about: the number simply stays as it
            // was until the next one succeeds.
        }).catch(() => {})
    }, [])

    /*
     * FOUR things resync the number, and the timer is the slowest of them (user decision
     * 2026-08-10: "the count should already be right, without opening the bell").
     *
     * Until then the badge leaned on the deferred `bellCount` prop plus a 45s tick, and that
     * combination has a real hole: a partial reload — which is EVERY list search, sort, filter
     * and page change in this app (`only: ['list','filters']`) — carries no shared props at
     * all, so the prop simply never refreshed on the pages users sit on longest.
     *
     *   1. on mount          — the number is right on arrival, not one interval later
     *   2. every POLL_MS     — the floor for someone who neither navigates nor switches tabs
     *   3. tab becomes visible — refresh happens when the user actually looks, which is what
     *                            makes a 60s interval feel instant
     *   4. every completed Inertia visit — including partial reloads, and including the
     *                            mark-all-read POST, whose own redirect lands here too
     *
     * It stands down entirely while the dropdown is open: there the feed owns the number, and
     * a background tick would fight it.
     */
    useEffect(() => {
        if (open) return undefined

        refreshCount()

        const timer = setInterval(refreshCount, POLL_MS)
        const onVisible = () => { if (document.visibilityState === 'visible') refreshCount() }
        const offSuccess = router.on('success', () => refreshCount())

        document.addEventListener('visibilitychange', onVisible)

        return () => {
            clearInterval(timer)
            document.removeEventListener('visibilitychange', onVisible)
            offSuccess()
        }
    }, [open, refreshCount])

    const load = useCallback(() => {
        const token = ++reqRef.current
        setLoading(true)
        setFailed(false)

        http.get(route('notifications.feed'), {
            onSuccess: (data) => {
                if (token !== reqRef.current) return
                setItems(Array.isArray(data?.items) ? data.items : [])
                setUnread(Number(data?.unread) || 0)
            },
            // useHttp splits failures into three callbacks (unlike router's single onError).
            // All three land on the same outcome here: we could not load the list.
            onError: () => { if (token === reqRef.current) setFailed(true) },
            onHttpException: () => { if (token === reqRef.current) setFailed(true) },
            onNetworkError: () => { if (token === reqRef.current) setFailed(true) },
            onFinish: () => { if (token === reqRef.current) setLoading(false) },
            // submit() rethrows on every failure path including cancel; the callbacks above
            // already own the outcome, so this only stops an uncaught promise rejection.
        }).catch(() => {})
    }, [http])

    const toggle = (e) => {
        // The collapsed rail expands itself on click (the <aside> has its own handler), so
        // every interactive child on it has to stop the event or opening the bell would also
        // un-collapse the sidebar underneath it.
        e.stopPropagation()

        if (open) {
            close()

            return
        }

        const r = e.currentTarget.getBoundingClientRect()
        const left = Math.max(8, Math.min(r.left, window.innerWidth - PANEL_WIDTH - 8))
        const spaceBelow = window.innerHeight - r.bottom - 12

        setPos(spaceBelow < 320 && r.top > spaceBelow
            ? { left, bottom: window.innerHeight - r.top + 8, maxH: Math.max(220, Math.min(460, r.top - 16)) }
            : { left, top: r.bottom + 8, maxH: Math.max(220, Math.min(460, spaceBelow)) })

        load()
    }

    const markAllRead = (e) => {
        e.stopPropagation()

        router.post(route('notifications.read-all'), {}, {
            preserveScroll: true,
            onSuccess: () => {
                setUnread(0)
                // The feed is unread-only, so "mark all as read" empties it. Flipping the rows
                // to isRead instead would leave the panel showing items the server would no
                // longer return — the list would disagree with the badge until the next open.
                setItems([])
            },
            // A JsonResponse endpoint would need a client toast; this one redirects, so the
            // server owns the success toast (.claude/rules/notifications.md). Only the
            // failure path is ours.
            onError: () => showToast('Could not mark them as read.', 'error'),
        })
    }

    const label = shown > 0
        ? `Notifications — ${shown} unread`
        : 'Notifications — nothing unread'

    return (
        <span ref={wrapRef} className="relative inline-block flex-shrink-0">
            <button
                ref={triggerRef}
                type="button"
                onClick={toggle}
                aria-haspopup="dialog"
                aria-expanded={open}
                aria-label={label}
                title={label}
                className={`relative inline-grid size-[30px] place-items-center rounded-lg transition-colors ${
                    open ? 'bg-sidebar-hover text-primary' : 'text-text-muted hover:bg-sidebar-hover hover:text-primary'
                }`}
            >
                <Bell size={16} />

                {shown > 0 && (collapsed ? (
                    // No room for digits on the rail — a presence dot, same language as the
                    // section icons above it.
                    <span aria-hidden="true" className="absolute right-0.5 top-0.5 size-[7px] rounded-full bg-primary" />
                ) : (
                    // FLAT, never gradient — a count is a state marker, not an action
                    // (.claude/rules/ui-conventions.md, "⛔ BUTTON GRADIENT").
                    <span
                        aria-hidden="true"
                        className="absolute -right-1 -top-1 inline-grid h-[15px] min-w-[15px] place-items-center rounded-full bg-primary px-1 text-[9px] font-bold leading-none tabular-nums text-white"
                    >
                        {shown > 999 ? '999+' : shown}
                    </span>
                ))}
            </button>

            {open && createPortal(
                <div
                    ref={panelRef}
                    // Marks this as sidebar UI that merely LIVES in document.body, so
                    // AppLayout's collapse-on-outside-click leaves it alone. Without it, the
                    // mousedown that opens an item collapses the sidebar, this panel unmounts,
                    // and the click never reaches the link.
                    data-sidebar-portal=""
                    role="dialog"
                    aria-label="Notifications"
                    tabIndex={-1}
                    onClick={(e) => e.stopPropagation()}
                    className="fixed z-[120] max-w-[92vw] overflow-hidden rounded-2xl border border-border bg-card text-left shadow-modal outline-none"
                    style={{ left: pos.left, top: pos.top, bottom: pos.bottom, width: PANEL_WIDTH }}
                >
                    <div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
                        <span className="text-xs font-bold uppercase tracking-wider text-text-heading">
                            Notifications
                        </span>
                        {shown > 0 && (
                            <button
                                type="button"
                                onClick={markAllRead}
                                className="text-[11px] font-bold text-primary transition-colors hover:underline"
                            >
                                Mark all as read
                            </button>
                        )}
                    </div>

                    <div className="overflow-y-auto" style={{ maxHeight: pos.maxH }}>
                        <BellBody
                            loading={loading}
                            failed={failed}
                            items={items}
                            // Wrapped, not passed by reference: close() now takes an options
                            // object and the row would hand it a MouseEvent. Also collapses
                            // the sidebar, matching what every nav leaf does after navigating
                            // — the bell should not be the one control that leaves it open.
                            onNavigate={() => { close(); onNavigate?.() }}
                        />
                    </div>

                    {/* There is no "See all" page — the dropdown IS the inbox (user decision
                        2026-08-09). Saying so beats letting the list look complete when it is
                        capped, which is the mistake the sidebar badges were careful to avoid. */}
                    {items !== null && shown > items.length && (
                        <div className="border-t border-border px-4 py-2 text-[11px] font-medium text-text-muted">
                            Showing the {items.length} most recent of {shown} unread.
                        </div>
                    )}
                </div>,
                document.body,
            )}
        </span>
    )
}

function BellBody({ loading, failed, items, onNavigate }) {
    if (loading && items === null) {
        return <p className="px-4 py-6 text-center text-xs font-medium text-text-muted">Loading…</p>
    }

    // Distinguishing "could not load" from "nothing here" matters: they look identical and
    // mean opposite things.
    if (failed) {
        return <p className="px-4 py-6 text-center text-xs font-medium text-danger">Could not load notifications.</p>
    }

    // Reached both when nothing has ever arrived and right after "mark all as read" — the two
    // now mean the same thing to the user, which is the point of an unread-only list.
    if (!items?.length) {
        return <p className="px-4 py-6 text-center text-xs font-medium text-text-muted">Nothing yet.</p>
    }

    /*
     * One flat list, newest first — no "New"/"Earlier" grouping any more.
     *
     * The feed stopped returning read rows (user decision 2026-08-10), so the "Earlier" group
     * could never have rows and a lone "New" heading over the only group is a label with
     * nothing to distinguish it from.
     */
    return (
        <ul className="m-0 flex list-none flex-col p-0">
            {items.map((n) => (
                <li key={n.id} className="border-b border-border/60 last:border-b-0">
                    <NotificationRow item={n} onNavigate={onNavigate} />
                </li>
            ))}
        </ul>
    )
}

function NotificationRow({ item, onNavigate }) {
    const body = (
        <>
            <div className="flex items-start gap-2">
                {/* Unread marker is a dot, not bold text — bold competes with the title. */}
                <span
                    aria-hidden="true"
                    className={`mt-1.5 size-[7px] shrink-0 rounded-full ${item.isRead ? 'bg-transparent' : 'bg-primary'}`}
                />
                <span className="min-w-0 flex-1">
                    <span className="block text-[12px] font-bold leading-snug text-text-heading">{item.title}</span>
                    {item.message && (
                        <span className="mt-0.5 block wrap-break-word text-[11px] leading-snug text-text-muted">
                            {item.message}
                        </span>
                    )}
                    <span className="mt-1 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-text-placeholder">
                        <span>{item.module}</span>
                        <span aria-hidden="true">·</span>
                        <span className="normal-case tracking-normal">{formatWhen(item.tanggal)}</span>
                    </span>
                </span>
            </div>
            <span className="sr-only">{item.isRead ? ' (read)' : ' (unread)'}</span>
        </>
    )

    // openUrl is null when the catalog no longer offers this event's link — the row still
    // shows what happened, it just has nowhere to go. Rendering a dead <a> would be worse.
    if (!item.openUrl) {
        return <div className="block px-4 py-3 opacity-70">{body}</div>
    }

    return (
        <Link
            href={item.openUrl}
            onClick={onNavigate}
            className="block px-4 py-3 transition-colors hover:bg-sidebar-hover"
        >
            {body}
        </Link>
    )
}

/** `2026-08-09T14:03:00+07:00` → `Today 14:03` / `9 Aug 14:03`. */
function formatWhen(iso) {
    if (!iso) return ''

    const d = new Date(iso)
    if (Number.isNaN(d.getTime())) return ''

    const now = new Date()
    const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
    const sameDay = d.getFullYear() === now.getFullYear()
        && d.getMonth() === now.getMonth()
        && d.getDate() === now.getDate()

    if (sameDay) return `Today ${time}`

    return `${d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })} ${time}`
}
