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
// Mirrors App\Models\Notification::FEED_LIMIT / FEED_MAX. The server clamps to these anyway;
// the client holds them so it knows when "View more" has nothing left to ask for and must stop
// offering itself.
const FEED_PAGE = 15
const FEED_MAX = 100

/**
 * 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)
    // How many rows the last request asked for. Reset on every open so the panel starts small.
    const [limit, setLimit] = useState(FEED_PAGE)

    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()
    }, [])

    /**
     * Put the panel beside the bell, given the bell's CURRENT position.
     *
     * Split out of `toggle` so a viewport change can re-place an OPEN panel instead of closing
     * it — see the resize handler. Returns false when the trigger has no box left to anchor to
     * (unmounted, or hidden by a layout change), which is the one case that must still close.
     */
    const place = useCallback(() => {
        const el = triggerRef.current
        const r = el?.getBoundingClientRect()

        if (!r || (r.width === 0 && r.height === 0)) return false

        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)) })

        return true
    }, [])

    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()
        }
        // ⛔ Do NOT turn this back into `close()`.
        //
        // On a phone the browser's address bar collapses and expands AS YOU SCROLL, and every one
        // of those fires `resize`. Closing on resize therefore made the dropdown vanish the moment
        // you tried to scroll it — reported 2026-08-27 as "notification di sidebar gabisa di
        // scroll". It is invisible in a headless browser and on desktop, because neither has an
        // address bar that moves: the list scrolls perfectly there, which is exactly why this
        // survived so long.
        //
        // Re-placing is also strictly better than closing on desktop: dragging a window edge now
        // slides the panel back beside its bell instead of dismissing it. The only case that still
        // closes is a trigger with no box left to anchor to, which `place()` reports as false.
        const onResize = () => { if (!place()) 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, place])

    // 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((size = FEED_PAGE) => {
        const token = ++reqRef.current
        setLoading(true)
        setFailed(false)

        http.get(route('notifications.feed', { limit: size }), {
            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
        }

        if (!place()) return

        setLimit(FEED_PAGE)
        load(FEED_PAGE)
    }

    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.
                        Since the dropdown IS the inbox, it also has to be able to reach past the
                        first page: "View more" (user 2026-08-28) grows THIS list rather than
                        opening a screen the 2026-08-09 decision deliberately did not build.
                        The button appears only while there is genuinely more to fetch — at
                        FEED_MAX the sentence stays and the button goes, because a control that
                        cannot change anything is worse than no control. */}
                    {items !== null && shown > items.length && (
                        <div className="flex items-center gap-2 border-t border-border px-4 py-2 text-[11px] font-medium text-text-muted">
                            <span>
                                Showing the {items.length} most recent of {shown} unread
                                {items.length >= FEED_MAX ? ' · this is as far as the list goes' : ''}.
                            </span>
                            {items.length < FEED_MAX && (
                                <button
                                    type="button"
                                    onClick={() => { const next = Math.min(limit + FEED_PAGE, FEED_MAX); setLimit(next); load(next) }}
                                    disabled={loading}
                                    className="ml-auto shrink-0 cursor-pointer rounded-md px-1.5 py-0.5 text-[11px] font-bold text-primary transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
                                >
                                    {loading ? 'Loading…' : 'View more'}
                                </button>
                            )}
                        </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}`
}
