<script lang="ts">
    import { onMount, onDestroy, tick } from 'svelte';
    import { ApiError } from '$lib/api/client';
    import {
        reenqueueAnalysis,
        analyzePage,
        getAnalysisMangaCoverage,
        getAnalysisChapterCoverage,
        getAnalysisChapterPages,
        getAnalysisPageDetail,
        analysisStatusStreamUrl,
        type MangaCoverage,
        type ChapterCoverage,
        type PageStatusItem,
        type PageAnalysisDetail,
        type ReenqueueAnalysisOptions,
        type AnalysisEvent
    } from '$lib/api/admin';
    import { chapterLabel } from '$lib/api/chapters';
    import { CancellableLoader } from '$lib/util/cancellable';
    import CoverageBadge from '$lib/components/CoverageBadge.svelte';
    import Modal from '$lib/components/Modal.svelte';
    import AnalysisHistoryTable from '$lib/components/analysis/AnalysisHistoryTable.svelte';
    import AnalysisMetricsPanel from '$lib/components/analysis/AnalysisMetricsPanel.svelte';
    import SegmentedControl from '$lib/components/SegmentedControl.svelte';

    const LIMIT = 25;

    // Segmented Live/History/Metrics view. Live = the SSE coverage
    // dashboard; History = a searchable log; Metrics = durations/averages.
    let view = $state<'live' | 'history' | 'metrics'>('live');

    // Global enqueue toggle.
    let includeAnalyzed = $state(false);

    // Coverage overview / search.
    let query = $state('');
    let searchTimer: ReturnType<typeof setTimeout> | null = null;
    let mangas = $state<MangaCoverage[]>([]);
    let total = $state(0);
    let offset = $state(0);
    let loading = $state(true);
    let loadingMore = $state(false);

    // Drill-down state.
    let expandedManga = $state<string | null>(null);
    let chapters = $state<Record<string, 'loading' | ChapterCoverage[]>>({});
    let expandedChapter = $state<string | null>(null);
    let pages = $state<Record<string, 'loading' | PageStatusItem[]>>({});

    // Page-detail modal.
    let detailOpen = $state(false);
    let detail = $state<PageAnalysisDetail | null>(null);
    let detailLoading = $state(false);
    let detailError = $state<string | null>(null);
    let detailQueueBusy = $state(false);

    // Enqueue feedback.
    let busyKey = $state<string | null>(null);
    let notice = $state<string | null>(null);
    let error = $state<string | null>(null);

    // --- Live status (SSE) ---
    type LiveStatus = 'analyzing' | 'done' | 'failed' | 'queued';
    // Per-page override applied on top of the fetched grid status.
    let liveStatus = $state<Record<string, LiveStatus>>({});
    // Pages we've already live-counted into coverage, so a repeat
    // `completed` can't double-bump a badge.
    let counted = new Set<string>();
    let ticker = $state<{ id: number; text: string }[]>([]);
    let tickerSeq = 0;
    let live = $state(false);
    let source: EventSource | null = null;
    let sseErrors = 0;
    // Per-loader cancellation guard. A debounced search-keystroke burst
    // (or unmount) cancels its predecessor's response — without this,
    // a slow first request would overwrite a faster second one.
    // `CancellableLoader` lifts the four invariants (predecessor abort,
    // late-result suppression, AbortError swallow, ownership-aware
    // finally) into $lib/util/cancellable, where they're unit-tested.
    // The inlined version in 0.87.9 was structurally untested.
    const coverageLoader = new CancellableLoader();

    // Global "now analyzing" pointer, driven by the `started` SSE event so
    // the operator sees what the worker is on even when nothing is
    // expanded. `phase` flips to done/failed for a moment on the matching
    // completed/failed event, then the banner clears.
    type NowAnalyzing = {
        manga_id: string;
        manga_title: string;
        chapter_id: string;
        chapter_number: number;
        page_id: string;
        page_number: number;
        phase: 'analyzing' | 'done' | 'failed';
    };
    let nowAnalyzing = $state<NowAnalyzing | null>(null);
    let clearTimer: ReturnType<typeof setTimeout> | null = null;

    function scheduleClear(pageId: string) {
        if (clearTimer) clearTimeout(clearTimer);
        clearTimer = setTimeout(() => {
            // Don't clear if a newer page took over the banner.
            if (nowAnalyzing?.page_id === pageId) nowAnalyzing = null;
        }, 4000);
    }

    /** Switch to Live, expand the now-analyzing chapter, scroll its chip. */
    async function jumpToNowAnalyzing() {
        const t = nowAnalyzing;
        if (!t) return;
        view = 'live';
        await tick();
        if (expandedManga !== t.manga_id) await toggleManga(t.manga_id);
        if (expandedChapter !== t.chapter_id) await toggleChapter(t.chapter_id);
        await tick();
        document
            .querySelector(`[data-testid="admin-analysis-page-${t.page_id}"]`)
            ?.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }

    onMount(() => {
        void loadCoverage(true);
        openStream();
        if (typeof document !== 'undefined') {
            document.addEventListener('visibilitychange', onVisibilityChange);
            window.addEventListener('pagehide', onPageHide);
            window.addEventListener('pageshow', onPageShow);
        }
    });

    onDestroy(() => {
        closeStream();
        if (typeof document !== 'undefined') {
            document.removeEventListener('visibilitychange', onVisibilityChange);
            window.removeEventListener('pagehide', onPageHide);
            window.removeEventListener('pageshow', onPageShow);
        }
        if (clearTimer) clearTimeout(clearTimer);
        // Cancel any in-flight coverage fetch so a late response can't
        // overwrite state after the page is gone.
        coverageLoader.cancel();
    });

    function pushTicker(text: string) {
        ticker = [{ id: ++tickerSeq, text }, ...ticker].slice(0, 8);
    }

    /** Effective chip status: live override wins over the fetched value. */
    function chipStatus(p: PageStatusItem): string {
        return liveStatus[p.page_id] ?? p.status;
    }

    /** mangaId that owns a loaded chapter, via the chapters cache. */
    function mangaOfChapter(chapterId: string): string | null {
        for (const [mid, val] of Object.entries(chapters)) {
            if (Array.isArray(val) && val.some((c) => c.chapter_id === chapterId)) {
                return mid;
            }
        }
        return null;
    }

    function bumpCoverage(mangaId: string, chapterId: string) {
        mangas = mangas.map((m) =>
            m.manga_id === mangaId
                ? {
                      ...m,
                      analyzed_pages: Math.min(m.total_pages, m.analyzed_pages + 1)
                  }
                : m
        );
        const chs = chapters[mangaId];
        if (Array.isArray(chs)) {
            chapters = {
                ...chapters,
                [mangaId]: chs.map((c) =>
                    c.chapter_id === chapterId
                        ? {
                              ...c,
                              analyzed_pages: Math.min(
                                  c.total_pages,
                                  c.analyzed_pages + 1
                              )
                          }
                        : c
                )
            };
        }
    }

    /** Mark loaded, in-scope, not-yet-done pages as queued. */
    function markQueued(ev: Extract<AnalysisEvent, { kind: 'enqueued' }>) {
        const next = { ...liveStatus };
        for (const [chapterId, val] of Object.entries(pages)) {
            if (!Array.isArray(val)) continue;
            const inScope =
                ev.chapter_id != null
                    ? chapterId === ev.chapter_id
                    : ev.manga_id != null
                      ? mangaOfChapter(chapterId) === ev.manga_id
                      : true; // whole library
            if (!inScope) continue;
            for (const p of val) {
                const cur: string = next[p.page_id] ?? p.status;
                if (cur === 'none' || cur === 'failed') next[p.page_id] = 'queued';
            }
        }
        liveStatus = next;
    }

    function applyEvent(ev: AnalysisEvent) {
        if (ev.kind === 'enqueued') {
            const scope = ev.chapter_id
                ? ' (chapter)'
                : ev.manga_id
                  ? ' (manga)'
                  : ' (library)';
            pushTicker(`Queued ${ev.count} page${ev.count === 1 ? '' : 's'}${scope}`);
            markQueued(ev);
            return;
        }
        if (ev.kind === 'started') {
            liveStatus = { ...liveStatus, [ev.page_id]: 'analyzing' };
            if (clearTimer) clearTimeout(clearTimer);
            nowAnalyzing = {
                manga_id: ev.manga_id,
                manga_title: ev.manga_title,
                chapter_id: ev.chapter_id,
                chapter_number: ev.chapter_number,
                page_id: ev.page_id,
                page_number: ev.page_number,
                phase: 'analyzing'
            };
            pushTicker(`Analyzing ${ev.manga_title} · p${ev.page_number}`);
        } else if (ev.kind === 'completed') {
            liveStatus = { ...liveStatus, [ev.page_id]: 'done' };
            if (!counted.has(ev.page_id)) {
                counted.add(ev.page_id);
                bumpCoverage(ev.manga_id, ev.chapter_id);
            }
            if (nowAnalyzing?.page_id === ev.page_id) {
                nowAnalyzing = { ...nowAnalyzing, phase: 'done' };
                scheduleClear(ev.page_id);
            }
            pushTicker(`✓ Analyzed page ${ev.page_number}`);
        } else if (ev.kind === 'failed') {
            liveStatus = { ...liveStatus, [ev.page_id]: 'failed' };
            if (nowAnalyzing?.page_id === ev.page_id) {
                nowAnalyzing = { ...nowAnalyzing, phase: 'failed' };
                scheduleClear(ev.page_id);
            }
            pushTicker(`✗ Failed page ${ev.page_number}`);
        } else if (ev.kind === 'cancelled') {
            // Daemon shutdown / analysis toggled off mid-dispatch. The
            // lease was released, not failed — the page is still pending
            // server-side. We just need to clear the "Analyzing pN" banner
            // and roll the per-page live status back to queued so the
            // chip doesn't stay spinner-frozen forever.
            const cur = liveStatus[ev.page_id];
            if (cur === 'analyzing') {
                liveStatus = { ...liveStatus, [ev.page_id]: 'queued' };
            }
            if (nowAnalyzing?.page_id === ev.page_id) {
                nowAnalyzing = null;
                if (clearTimer) clearTimeout(clearTimer);
            }
            pushTicker(`↺ Cancelled page ${ev.page_number}`);
        }
    }

    function closeStream() {
        source?.close();
        source = null;
        live = false;
    }

    // Mirrors `admin/crawler/+page.svelte`: close the SSE stream when
    // the tab is hidden so the browser doesn't accumulate a stale
    // connection (mobile Safari throttles SSE aggressively in background
    // tabs), and reopen on visible. Plus pagehide / pageshow to handle
    // the BFCache, which would otherwise leave the dashboard reading a
    // dead connection on back-navigation.
    function onVisibilityChange() {
        if (typeof document === 'undefined') return;
        if (document.visibilityState === 'hidden') {
            closeStream();
        } else if (document.visibilityState === 'visible') {
            openStream();
        }
    }
    function onPageHide() {
        closeStream();
    }
    function onPageShow() {
        openStream();
    }

    function openStream() {
        if (source) return;
        const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true });
        es.addEventListener('analysis', (e) => {
            try {
                applyEvent(JSON.parse((e as MessageEvent).data) as AnalysisEvent);
                live = true;
                sseErrors = 0;
            } catch {
                // ignore a malformed frame
            }
        });
        // Dropped frames — resync the overview from the server.
        es.addEventListener('lagged', () => void loadCoverage(true));
        es.onopen = () => {
            live = true;
            sseErrors = 0;
        };
        es.onerror = () => {
            live = false;
            sseErrors += 1;
            // EventSource hides the status code; after several failures,
            // probe a normal admin call so the global on401 hook can fire.
            if (sseErrors >= 4) {
                sseErrors = 0;
                getAnalysisMangaCoverage({ limit: 1 }).catch(() => {});
            }
        };
        source = es;
    }

    function onSearchInput() {
        if (searchTimer) clearTimeout(searchTimer);
        searchTimer = setTimeout(() => loadCoverage(true), 300);
    }

    async function loadCoverage(reset: boolean) {
        if (reset) {
            offset = 0;
            loading = true;
            // Collapse open rows so stale chapter/page state doesn't linger.
            expandedManga = null;
            expandedChapter = null;
            // Server numbers are authoritative again — drop live overrides.
            liveStatus = {};
            counted = new Set();
        } else {
            loadingMore = true;
        }
        error = null;
        try {
            const r = await coverageLoader.run(({ signal }) =>
                getAnalysisMangaCoverage(
                    {
                        search: query.trim() || undefined,
                        limit: LIMIT,
                        offset
                    },
                    { signal }
                )
            );
            // `null` = superseded by a successor call, or AbortError.
            // Skip state updates AND leave the spinner alone — the
            // successor (which is still in-flight) owns it. Without
            // this guard, an unconditional `loading = false` in a
            // finally{} would clear the spinner while the successor
            // is still loading, producing a flicker.
            if (r === null) return;
            mangas = reset ? r.items : [...mangas, ...r.items];
            total = r.page.total ?? mangas.length;
            loading = false;
            loadingMore = false;
        } catch (e) {
            // Reaching this catch means run() rejected for a call
            // that still owned the loader — a superseded call would
            // have returned null instead (CancellableLoader contract,
            // 0.87.24). So this call genuinely failed and is allowed
            // to surface its error / clear the spinner.
            error = e instanceof ApiError ? e.message : 'Failed to load coverage.';
            loading = false;
            loadingMore = false;
        }
    }

    async function loadMore() {
        offset += LIMIT;
        await loadCoverage(false);
    }

    async function toggleManga(id: string) {
        if (expandedManga === id) {
            expandedManga = null;
            return;
        }
        expandedManga = id;
        expandedChapter = null;
        if (!chapters[id]) {
            chapters[id] = 'loading';
            try {
                chapters[id] = await getAnalysisChapterCoverage(id);
            } catch (e) {
                delete chapters[id];
                expandedManga = null;
                error = e instanceof ApiError ? e.message : 'Failed to load chapters.';
            }
        }
    }

    async function toggleChapter(id: string) {
        if (expandedChapter === id) {
            expandedChapter = null;
            return;
        }
        expandedChapter = id;
        await loadPages(id);
    }

    async function loadPages(id: string) {
        pages[id] = 'loading';
        try {
            const fresh = await getAnalysisChapterPages(id);
            // Backend status is authoritative for just-fetched pages; drop
            // any live overrides for them so the grid reflects the server.
            const next = { ...liveStatus };
            for (const p of fresh) delete next[p.page_id];
            liveStatus = next;
            pages[id] = fresh;
        } catch (e) {
            delete pages[id];
            error = e instanceof ApiError ? e.message : 'Failed to load pages.';
        }
    }

    async function openDetail(pageId: string) {
        detailOpen = true;
        detailLoading = true;
        detailError = null;
        detail = null;
        try {
            detail = await getAnalysisPageDetail(pageId);
        } catch (e) {
            detailError = e instanceof ApiError ? e.message : 'Failed to load page.';
        } finally {
            detailLoading = false;
        }
    }

    function closeDetail() {
        detailOpen = false;
    }

    async function queueFromDetail() {
        if (!detail || detailQueueBusy) return;
        detailQueueBusy = true;
        detailError = null;
        try {
            await analyzePage(detail.page_id);
            // Reflect the new queued state in the grid + modal.
            if (expandedChapter) await loadPages(expandedChapter);
            notice = `Queued page ${detail.page_number} for analysis.`;
            closeDetail();
        } catch (e) {
            detailError =
                e instanceof ApiError && e.status === 503
                    ? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
                    : e instanceof ApiError
                      ? e.message
                      : 'Failed to queue page.';
        } finally {
            detailQueueBusy = false;
        }
    }

    async function enqueue(
        key: string,
        scope: Omit<ReenqueueAnalysisOptions, 'onlyUnanalyzed'>,
        label: string
    ) {
        if (busyKey) return;
        busyKey = key;
        notice = null;
        error = null;
        try {
            const r = await reenqueueAnalysis({
                ...scope,
                onlyUnanalyzed: !includeAnalyzed
            });
            notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis (${label}).`;
            // Refresh the open chapter's page grid so queued pages show.
            if (expandedChapter) await loadPages(expandedChapter);
        } catch (e) {
            if (e instanceof ApiError) {
                error =
                    e.status === 503
                        ? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
                        : e.message;
            } else {
                error = 'Failed to enqueue analysis.';
            }
        } finally {
            busyKey = null;
        }
    }

    const enqueueLibrary = () => enqueue('library', {}, 'whole library');
    const enqueueManga = (m: MangaCoverage) =>
        enqueue(`manga:${m.manga_id}`, { mangaId: m.manga_id }, m.title);
    const enqueueChapter = (m: MangaCoverage, c: ChapterCoverage) =>
        enqueue(
            `chapter:${c.chapter_id}`,
            { chapterId: c.chapter_id },
            `${m.title} · ${chapterLabel({ number: c.number, title: c.title })}`
        );

    function fmtDate(iso: string | null): string {
        if (!iso) return '';
        return new Date(iso).toLocaleString();
    }
</script>

<svelte:head>
    <title>Mangalord | Admin — Analysis</title>
</svelte:head>

<div class="title-row">
    <h1 class="heading">Content analysis</h1>
    <span
        class="live-pill"
        class:on={live}
        data-testid="admin-analysis-live"
        title={live ? 'Live updates connected' : 'Reconnecting…'}
    >
        <span class="live-dot"></span>
        {live ? 'Live' : 'Reconnecting…'}
    </span>
</div>
<p class="lede">
    Coverage of the OCR text-extraction worker. Browse which pages have had
    their text extracted, queue more, and inspect any page's OCR result.
    Updates stream live as pages are queued and processed.
</p>

<div class="viewtabs">
    <SegmentedControl
        options={[
            { label: 'Live', value: 'live' },
            { label: 'History', value: 'history' },
            { label: 'Metrics', value: 'metrics' }
        ]}
        value={view}
        onchange={(v) => (view = v)}
        ariaLabel="Analysis view"
        testid="admin-analysis-tab"
    />
</div>

{#if nowAnalyzing}
    <div
        class="now-analyzing {nowAnalyzing.phase}"
        data-testid="admin-analysis-now"
        aria-live="polite"
    >
        <span class="now-icon">
            {nowAnalyzing.phase === 'done' ? '✓' : nowAnalyzing.phase === 'failed' ? '✗' : '⟳'}
        </span>
        <span class="now-label">
            <strong
                >{nowAnalyzing.phase === 'done'
                    ? 'Analyzed'
                    : nowAnalyzing.phase === 'failed'
                      ? 'Failed'
                      : 'Now analyzing'}</strong
            >
            {nowAnalyzing.manga_title} · Ch {nowAnalyzing.chapter_number} · Page
            {nowAnalyzing.page_number}
        </span>
        <button
            type="button"
            class="now-jump"
            onclick={jumpToNowAnalyzing}
            data-testid="admin-analysis-now-jump"
        >
            Jump →
        </button>
    </div>
{/if}

{#if view === 'history'}
    <AnalysisHistoryTable onOpenDetail={openDetail} />
{/if}

{#if view === 'metrics'}
    <AnalysisMetricsPanel />
{/if}

{#if view === 'live'}
{#if ticker.length > 0}
    <ul class="ticker" data-testid="admin-analysis-ticker" aria-live="polite">
        {#each ticker as t (t.id)}
            <li>{t.text}</li>
        {/each}
    </ul>
{/if}

<label class="checkbox">
    <input
        type="checkbox"
        bind:checked={includeAnalyzed}
        data-testid="admin-analysis-include-analyzed"
    />
    <span>
        Include already-analyzed pages
        <span class="muted">
            — re-runs analysis on pages that already have results (default
            off). Applies to every "Queue" action.
        </span>
    </span>
</label>

<div class="row library-row">
    <span class="row-title">Whole library</span>
    <button
        type="button"
        disabled={busyKey !== null}
        onclick={enqueueLibrary}
        data-testid="admin-analysis-enqueue-library"
    >
        {busyKey === 'library' ? 'Queueing…' : 'Queue all'}
    </button>
</div>

<label class="field">
    <span>Search</span>
    <input
        type="text"
        bind:value={query}
        oninput={onSearchInput}
        placeholder="Filter mangas by title…"
        data-testid="admin-analysis-search"
    />
</label>

{#if loading}
    <p class="muted" data-testid="admin-analysis-loading">Loading coverage…</p>
{:else if mangas.length === 0}
    <p class="muted" data-testid="admin-analysis-empty">
        No mangas with pages{query.trim() ? ` match "${query.trim()}"` : ''}.
    </p>
{:else}
    <ul class="results" data-testid="admin-analysis-results">
        {#each mangas as m (m.manga_id)}
            <li class="manga" data-testid={`admin-analysis-manga-${m.manga_id}`}>
                <div class="row">
                    <button
                        type="button"
                        class="disclosure"
                        aria-expanded={expandedManga === m.manga_id}
                        onclick={() => toggleManga(m.manga_id)}
                        data-testid={`admin-analysis-expand-${m.manga_id}`}
                    >
                        <span class="caret" class:open={expandedManga === m.manga_id}>▸</span>
                        <span class="row-main">
                            <span class="row-title">{m.title}</span>
                        </span>
                    </button>
                    <CoverageBadge
                        analyzed={m.analyzed_pages}
                        total={m.total_pages}
                        testid={`admin-analysis-coverage-manga-${m.manga_id}`}
                    />
                    <button
                        type="button"
                        disabled={busyKey !== null}
                        onclick={() => enqueueManga(m)}
                        data-testid={`admin-analysis-enqueue-manga-${m.manga_id}`}
                    >
                        {busyKey === `manga:${m.manga_id}` ? 'Queueing…' : 'Queue manga'}
                    </button>
                </div>

                {#if expandedManga === m.manga_id}
                    {#if chapters[m.manga_id] === 'loading'}
                        <p class="muted indent">Loading chapters…</p>
                    {:else if Array.isArray(chapters[m.manga_id])}
                        {@const chs = chapters[m.manga_id] as ChapterCoverage[]}
                        <ul class="chapters">
                            {#each chs as c (c.chapter_id)}
                                <li
                                    class="chapter"
                                    data-testid={`admin-analysis-chapter-${c.chapter_id}`}
                                >
                                    <div class="row">
                                        <button
                                            type="button"
                                            class="disclosure"
                                            aria-expanded={expandedChapter === c.chapter_id}
                                            onclick={() => toggleChapter(c.chapter_id)}
                                            data-testid={`admin-analysis-expand-chapter-${c.chapter_id}`}
                                        >
                                            <span
                                                class="caret"
                                                class:open={expandedChapter === c.chapter_id}
                                                >▸</span
                                            >
                                            <span class="row-main">
                                                <span class="chapter-label">
                                                    {chapterLabel({
                                                        number: c.number,
                                                        title: c.title
                                                    })}
                                                </span>
                                            </span>
                                        </button>
                                        <CoverageBadge
                                            analyzed={c.analyzed_pages}
                                            total={c.total_pages}
                                            testid={`admin-analysis-coverage-chapter-${c.chapter_id}`}
                                        />
                                        <button
                                            type="button"
                                            disabled={busyKey !== null}
                                            onclick={() => enqueueChapter(m, c)}
                                            data-testid={`admin-analysis-enqueue-chapter-${c.chapter_id}`}
                                        >
                                            {busyKey === `chapter:${c.chapter_id}`
                                                ? 'Queueing…'
                                                : 'Queue chapter'}
                                        </button>
                                    </div>

                                    {#if expandedChapter === c.chapter_id}
                                        {#if pages[c.chapter_id] === 'loading'}
                                            <p class="muted indent">Loading pages…</p>
                                        {:else if Array.isArray(pages[c.chapter_id])}
                                            {@const ps = pages[c.chapter_id] as PageStatusItem[]}
                                            <div
                                                class="page-grid"
                                                data-testid={`admin-analysis-pages-${c.chapter_id}`}
                                            >
                                                {#each ps as p (p.page_id)}
                                                    <button
                                                        type="button"
                                                        class="page-chip {chipStatus(p)}"
                                                        title={`Page ${p.page_number} — ${chipStatus(p)}`}
                                                        onclick={() => openDetail(p.page_id)}
                                                        data-testid={`admin-analysis-page-${p.page_id}`}
                                                        data-status={chipStatus(p)}
                                                    >
                                                        {p.page_number}
                                                    </button>
                                                {/each}
                                            </div>
                                            <p class="legend">
                                                <span class="dot done"></span> analyzed
                                                <span class="dot analyzing"></span> analyzing
                                                <span class="dot queued"></span> queued
                                                <span class="dot failed"></span> failed
                                                <span class="dot none"></span> not analyzed
                                            </p>
                                        {/if}
                                    {/if}
                                </li>
                            {/each}
                        </ul>
                    {/if}
                {/if}
            </li>
        {/each}
    </ul>

    {#if mangas.length < total}
        <button
            type="button"
            class="load-more"
            disabled={loadingMore}
            onclick={loadMore}
            data-testid="admin-analysis-load-more"
        >
            {loadingMore ? 'Loading…' : `Load more (${mangas.length}/${total})`}
        </button>
    {/if}
{/if}

{#if notice}
    <p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
{/if}
{#if error}
    <p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
{/if}
{/if}

<Modal
    open={detailOpen}
    title={detail ? `Page ${detail.page_number}` : 'Page analysis'}
    onClose={closeDetail}
    size="lg"
    closeOnBackdrop
    testid="admin-analysis-detail"
>
    {#if detailLoading}
        <p class="muted">Loading…</p>
    {:else if detailError}
        <p class="error" role="alert">{detailError}</p>
    {:else if detail}
        <div class="detail-meta">
            <span class="status-pill {detail.status}" data-testid="admin-analysis-detail-status">
                {detail.status === 'done'
                    ? 'Analyzed'
                    : detail.status === 'failed'
                      ? 'Failed'
                      : 'Not analyzed'}
            </span>
            {#if detail.model}<span class="muted">model {detail.model}</span>{/if}
            {#if detail.analyzed_at}<span class="muted">{fmtDate(detail.analyzed_at)}</span>{/if}
        </div>

        {#if detail.status === 'none'}
            <p class="muted">This page hasn't been analyzed yet.</p>
        {:else if detail.status === 'failed'}
            <p class="error">{detail.error ?? 'Analysis failed.'}</p>
        {/if}

        {#if detail.ocr.length > 0}
            <section class="block">
                <h3>OCR text</h3>
                <ul class="ocr">
                    {#each detail.ocr as line, i (i)}
                        <li>{#if line.kind}<span class="ocr-kind">{line.kind}</span> {/if}{line.text}</li>
                    {/each}
                </ul>
            </section>
        {/if}
    {/if}

    {#snippet footer()}
        {#if detail && detail.status !== 'done'}
            <button
                type="button"
                disabled={detailQueueBusy}
                onclick={queueFromDetail}
                data-testid="admin-analysis-detail-queue"
            >
                {detailQueueBusy ? 'Queueing…' : 'Queue this page'}
            </button>
        {/if}
        <button type="button" onclick={closeDetail}>Close</button>
    {/snippet}
</Modal>

<style>
    .title-row {
        display: flex;
        align-items: center;
        gap: var(--space-3);
        margin-bottom: var(--space-2);
    }
    .heading {
        margin-bottom: var(--space-2);
    }
    .title-row .heading {
        margin-bottom: 0;
    }
    .live-pill {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        font-size: var(--font-xs);
        font-weight: var(--weight-semibold);
        padding: 2px var(--space-2);
        border-radius: var(--radius-pill);
        background: var(--surface-elevated);
        color: var(--text-muted);
        border: 1px solid var(--border);
    }
    .live-pill.on {
        color: #2e7d32;
        border-color: color-mix(in srgb, #2e7d32 40%, transparent);
    }
    .live-dot {
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: currentColor;
    }
    .live-pill.on .live-dot {
        animation: pulse 1.6s ease-in-out infinite;
    }
    .viewtabs {
        margin-bottom: var(--space-3);
    }
    .now-analyzing {
        position: sticky;
        top: var(--space-2);
        z-index: 5;
        display: flex;
        align-items: center;
        gap: var(--space-3);
        max-width: 44rem;
        margin-bottom: var(--space-3);
        padding: var(--space-2) var(--space-3);
        border: 1px solid color-mix(in srgb, #d4762a 45%, transparent);
        border-radius: var(--radius-md);
        background: color-mix(in srgb, #d4762a 12%, transparent);
        font-size: var(--font-sm);
    }
    .now-analyzing.done {
        border-color: color-mix(in srgb, #2e7d32 45%, transparent);
        background: color-mix(in srgb, #2e7d32 12%, transparent);
    }
    .now-analyzing.failed {
        border-color: color-mix(in srgb, var(--danger) 45%, transparent);
        background: color-mix(in srgb, var(--danger) 12%, transparent);
    }
    .now-icon {
        font-size: var(--font-lg);
        color: #b85e1a;
    }
    .now-analyzing.analyzing .now-icon {
        animation: spin 1.4s linear infinite;
        display: inline-block;
    }
    .now-analyzing.done .now-icon {
        color: #2e7d32;
    }
    .now-analyzing.failed .now-icon {
        color: var(--danger);
    }
    .now-label {
        flex: 1;
        min-width: 0;
    }
    .now-jump {
        flex-shrink: 0;
    }
    @keyframes spin {
        to {
            transform: rotate(360deg);
        }
    }
    .ticker {
        list-style: none;
        margin: 0 0 var(--space-3);
        padding: var(--space-2) var(--space-3);
        max-width: 44rem;
        border: 1px solid var(--border);
        border-radius: var(--radius-md);
        background: var(--surface);
        font-size: var(--font-sm);
        color: var(--text-muted);
        display: flex;
        flex-direction: column;
        gap: 2px;
        max-height: 9rem;
        overflow: hidden;
    }
    @keyframes pulse {
        0%,
        100% {
            opacity: 1;
        }
        50% {
            opacity: 0.35;
        }
    }
    .lede {
        color: var(--text-muted);
        margin-bottom: var(--space-4);
        max-width: 44rem;
    }
    .checkbox {
        display: grid;
        grid-template-columns: auto 1fr;
        gap: var(--space-2);
        align-items: start;
        font-size: var(--font-sm);
        margin-bottom: var(--space-3);
        max-width: 44rem;
    }
    .muted {
        color: var(--text-muted);
    }
    .indent {
        margin-left: var(--space-5);
    }
    .library-row {
        max-width: 44rem;
        padding: var(--space-2) var(--space-3);
        border: 1px solid var(--border);
        border-radius: var(--radius-md);
        margin-bottom: var(--space-3);
    }
    .field {
        display: flex;
        flex-direction: column;
        gap: var(--space-1);
        margin-bottom: var(--space-3);
        max-width: 44rem;
    }
    .field > span {
        color: var(--text-muted);
        font-size: var(--font-sm);
    }
    .row {
        display: flex;
        align-items: center;
        gap: var(--space-2);
    }
    .row-main {
        display: flex;
        flex-direction: column;
        min-width: 0;
        flex: 1;
        text-align: left;
    }
    .row-title,
    .chapter-label {
        font-weight: var(--weight-semibold);
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
    .results {
        list-style: none;
        margin: 0;
        padding: 0;
        max-width: 44rem;
    }
    .manga {
        border-top: 1px solid var(--border);
        padding: var(--space-2) 0;
    }
    .disclosure {
        display: flex;
        align-items: center;
        gap: var(--space-2);
        flex: 1;
        min-width: 0;
        background: transparent;
        border: 0;
        padding: 0;
        cursor: pointer;
        color: inherit;
    }
    .caret {
        transition: transform 0.12s ease;
        color: var(--text-muted);
    }
    .caret.open {
        transform: rotate(90deg);
    }
    .chapters {
        list-style: none;
        margin: var(--space-2) 0 0 var(--space-4);
        padding: 0;
        display: flex;
        flex-direction: column;
        gap: var(--space-1);
    }
    .page-grid {
        display: flex;
        flex-wrap: wrap;
        gap: 4px;
        margin: var(--space-2) 0 0 var(--space-5);
    }
    .page-chip {
        min-width: 28px;
        height: 28px;
        padding: 0 4px;
        font-size: var(--font-xs);
        border-radius: var(--radius-sm);
        border: 1px solid var(--border);
        cursor: pointer;
        background: var(--surface-elevated);
        color: var(--text-muted);
    }
    .page-chip.done {
        background: color-mix(in srgb, #2e7d32 18%, transparent);
        border-color: color-mix(in srgb, #2e7d32 45%, transparent);
        color: #2e7d32;
    }
    .page-chip.queued {
        background: color-mix(in srgb, #2563eb 18%, transparent);
        border-color: color-mix(in srgb, #2563eb 45%, transparent);
        color: #2563eb;
    }
    .page-chip.analyzing {
        background: color-mix(in srgb, #d4762a 22%, transparent);
        border-color: color-mix(in srgb, #d4762a 55%, transparent);
        color: #b85e1a;
        animation: pulse 1.2s ease-in-out infinite;
    }
    .page-chip.failed {
        background: color-mix(in srgb, var(--danger) 18%, transparent);
        border-color: color-mix(in srgb, var(--danger) 45%, transparent);
        color: var(--danger);
    }
    .legend {
        display: flex;
        align-items: center;
        gap: var(--space-2);
        margin: var(--space-2) 0 0 var(--space-5);
        font-size: var(--font-xs);
        color: var(--text-muted);
    }
    .dot {
        display: inline-block;
        width: 10px;
        height: 10px;
        border-radius: 2px;
        margin-right: 2px;
        vertical-align: middle;
    }
    .dot.done {
        background: #2e7d32;
    }
    .dot.queued {
        background: #2563eb;
    }
    .dot.analyzing {
        background: #d4762a;
    }
    .dot.failed {
        background: var(--danger);
    }
    .dot.none {
        background: var(--surface-elevated);
        border: 1px solid var(--border);
    }
    .load-more {
        margin-top: var(--space-3);
    }
    .notice {
        color: var(--success, #2e7d32);
        max-width: 44rem;
    }
    .error {
        color: var(--danger);
        max-width: 44rem;
    }

    /* Detail modal */
    .detail-meta {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: var(--space-2);
        font-size: var(--font-sm);
        margin-bottom: var(--space-3);
    }
    .status-pill {
        font-weight: var(--weight-semibold);
        font-size: var(--font-xs);
        padding: 1px var(--space-2);
        border-radius: var(--radius-pill);
        background: var(--surface-elevated);
        color: var(--text-muted);
    }
    .status-pill.done {
        background: color-mix(in srgb, #2e7d32 16%, transparent);
        color: #2e7d32;
    }
    .status-pill.failed {
        background: color-mix(in srgb, var(--danger) 16%, transparent);
        color: var(--danger);
    }
    .block {
        margin-bottom: var(--space-3);
    }
    .block h3 {
        font-size: var(--font-sm);
        color: var(--text-muted);
        text-transform: uppercase;
        letter-spacing: 0.04em;
        margin: 0 0 var(--space-1);
    }
    .ocr {
        list-style: none;
        margin: 0;
        padding: 0;
        display: flex;
        flex-direction: column;
        gap: var(--space-1);
    }
    .ocr-kind {
        display: inline-block;
        min-width: 5.5rem;
        font-size: var(--font-xs);
        color: var(--text-muted);
        text-transform: uppercase;
    }
</style>
