import {
    describe,
    it,
    expect,
    vi,
    beforeEach,
    afterEach,
    type MockInstance
} from 'vitest';
import {
    listAdminUsers,
    deleteAdminUser,
    setUserAdmin,
    createAdminUser,
    listAdminMangas,
    listAdminChapters,
    getSystemStats,
    getOverviewStats,
    getStorageStats,
    backfillStorage,
    resyncManga,
    resyncChapter,
    getCrawlerStatus,
    crawlerStatusStreamUrl,
    runCrawlerPass,
    reconcileCrawler,
    restartCrawlerBrowser,
    updateCrawlerSession,
    clearCrawlerSessionExpired,
    listDeadJobs,
    requeueDeadJobs,
    listActiveJobs,
    listMissingCovers,
    reenqueueAnalysis,
    analyzePage,
    getAnalysisMangaCoverage,
    getAnalysisChapterCoverage,
    getAnalysisChapterPages,
    getAnalysisPageDetail,
    analysisStatusStreamUrl,
    listCrawlerJobHistory,
    listAnalysisHistory,
    getCrawlerMetrics,
    listCrawlerOps,
    getAnalysisMetrics,
    listAuditLog,
    getHealth,
    updateHealthThresholds,
    getCrawlerMetricsSeries,
    getAnalysisMetricsSeries
} from './admin';

function ok(body: unknown, status = 200): Response {
    return new Response(JSON.stringify(body), {
        status,
        headers: { 'content-type': 'application/json' }
    });
}

function noContent(): Response {
    return new Response(null, { status: 204 });
}

function envelope(status: number, code: string, message: string): Response {
    return new Response(JSON.stringify({ error: { code, message } }), {
        status,
        headers: { 'content-type': 'application/json' }
    });
}

const userFixture = {
    id: 'u-1',
    username: 'alice',
    created_at: '2026-01-01T00:00:00Z',
    is_admin: false
};

const mangaFixture = {
    id: 'm-1',
    title: 'Test',
    status: 'ongoing',
    cover_image_path: null,
    created_at: '2026-01-01T00:00:00Z',
    updated_at: '2026-01-01T00:00:00Z',
    sync_state: 'synced' as const,
    chapter_count: 3,
    latest_seen_at: '2026-01-02T00:00:00Z'
};

const systemFixture = {
    disk: {
        total_bytes: 1_000_000,
        used_bytes: 500_000,
        free_bytes: 500_000,
        percent_used: 50.0
    },
    memory: { total_bytes: 8_000_000, used_bytes: 4_000_000, percent_used: 50.0 },
    cpu: {
        percent_used: 12.3,
        load_avg: { one: 0.81, five: 0.66, fifteen: 0.59 },
        per_core: [10.0, 14.5, 8.2, 20.1]
    },
    temperatures: [
        { label: 'CPU Package', celsius: 52.0, max_celsius: 71.0, critical_celsius: 100.0 }
    ],
    alerts: []
};

describe('admin api client', () => {
    let fetchSpy: MockInstance<typeof globalThis.fetch>;

    beforeEach(() => {
        fetchSpy = vi.spyOn(globalThis, 'fetch');
    });
    afterEach(() => {
        vi.restoreAllMocks();
    });

    // ---- users ----

    it('listAdminUsers GETs /v1/admin/users and parses the paged envelope', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [userFixture], page: { limit: 50, offset: 0, total: 1 } })
        );
        const page = await listAdminUsers({ limit: 50 });
        expect(page.items).toHaveLength(1);
        expect(page.items[0]).toEqual(userFixture);
        expect(page.page.total).toBe(1);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/users\?limit=50$/);
    });

    it('listAdminUsers forwards search + offset query params', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 50, offset: 10, total: 0 } })
        );
        await listAdminUsers({ search: 'al', offset: 10 });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('search=al');
        expect(url).toContain('offset=10');
    });

    it('listAdminUsers surfaces 403 forbidden via ApiError.code', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(403, 'forbidden', 'forbidden'));
        await expect(listAdminUsers()).rejects.toMatchObject({
            status: 403,
            code: 'forbidden'
        });
    });

    it('deleteAdminUser DELETEs to /v1/admin/users/{id} and handles 204', async () => {
        fetchSpy.mockResolvedValueOnce(noContent());
        await expect(deleteAdminUser('u-1')).resolves.toBeUndefined();
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/users\/u-1$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('DELETE');
    });

    it('deleteAdminUser surfaces 409 conflict (self-delete / last-admin)', async () => {
        fetchSpy.mockResolvedValueOnce(
            envelope(409, 'conflict', 'cannot delete yourself; ask another admin')
        );
        await expect(deleteAdminUser('u-1')).rejects.toMatchObject({
            status: 409,
            code: 'conflict'
        });
    });

    it('createAdminUser POSTs to /v1/admin/users with body and returns the created user', async () => {
        const created = { ...userFixture, username: 'invited01' };
        fetchSpy.mockResolvedValueOnce(ok(created, 201));
        const got = await createAdminUser({
            username: 'invited01',
            password: 'freshpass1234'
        });
        expect(got).toEqual(created);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/users$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(JSON.parse(init.body as string)).toEqual({
            username: 'invited01',
            password: 'freshpass1234'
        });
    });

    it('createAdminUser forwards is_admin when provided', async () => {
        const created = { ...userFixture, username: 'coadmin', is_admin: true };
        fetchSpy.mockResolvedValueOnce(ok(created, 201));
        await createAdminUser({
            username: 'coadmin',
            password: 'freshpass1234',
            is_admin: true
        });
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(JSON.parse(init.body as string)).toEqual({
            username: 'coadmin',
            password: 'freshpass1234',
            is_admin: true
        });
    });

    it('createAdminUser surfaces 409 conflict on duplicate username', async () => {
        fetchSpy.mockResolvedValueOnce(
            envelope(409, 'conflict', 'username is already taken')
        );
        await expect(
            createAdminUser({ username: 'taken', password: 'freshpass1234' })
        ).rejects.toMatchObject({ status: 409, code: 'conflict' });
    });

    it('setUserAdmin PATCHes is_admin and returns the updated user', async () => {
        const updated = { ...userFixture, is_admin: true };
        fetchSpy.mockResolvedValueOnce(ok(updated));
        const got = await setUserAdmin('u-1', true);
        expect(got).toEqual(updated);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('PATCH');
        expect(JSON.parse(init.body as string)).toEqual({ is_admin: true });
    });

    // ---- mangas + chapters ----

    it('listAdminMangas GETs /v1/admin/mangas and forwards sync_state filter', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [mangaFixture], page: { limit: 100, offset: 0, total: 1 } })
        );
        const page = await listAdminMangas({ syncState: 'in_progress', limit: 100 });
        expect(page.items[0].sync_state).toBe('synced');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('sync_state=in_progress');
        expect(url).toContain('limit=100');
    });

    it('listAdminChapters GETs the nested chapter route and parses the paged envelope', async () => {
        const chapter = {
            id: 'c-1',
            manga_id: 'm-1',
            number: 1,
            title: null,
            page_count: 12,
            created_at: '2026-01-01T00:00:00Z',
            sync_state: 'synced' as const,
            latest_seen_at: null
        };
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [chapter], page: { limit: 200, offset: 0, total: 1 } })
        );
        const resp = await listAdminChapters('m-1');
        expect(resp.items).toEqual([chapter]);
        expect(resp.page.total).toBe(1);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/);
    });

    it('listAdminChapters forwards limit + offset query params', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 50, offset: 100, total: 0 } })
        );
        await listAdminChapters('m-1', { limit: 50, offset: 100 });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('limit=50');
        expect(url).toContain('offset=100');
    });

    // ---- system ----

    it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => {
        fetchSpy.mockResolvedValueOnce(ok(systemFixture));
        const s = await getSystemStats();
        expect(s.disk?.percent_used).toBe(50);
        expect(s.memory.percent_used).toBe(50);
        expect(s.cpu.percent_used).toBe(12.3);
        expect(s.cpu.load_avg.one).toBe(0.81);
        expect(s.cpu.per_core).toHaveLength(4);
        expect(s.temperatures[0].label).toBe('CPU Package');
        expect(s.temperatures[0].critical_celsius).toBe(100);
        expect(s.alerts).toEqual([]);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/system$/);
    });

    it('getSystemStats keeps disk null when backend reports a non-local store', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, disk: null }));
        const s = await getSystemStats();
        expect(s.disk).toBeNull();
    });

    it('getSystemStats tolerates an empty temperatures array (sensors unavailable)', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, temperatures: [] }));
        const s = await getSystemStats();
        expect(s.temperatures).toEqual([]);
    });

    it('getOverviewStats GETs /v1/admin/overview and parses the nested envelope', async () => {
        const fixture = {
            users: {
                total: 7,
                admins: 2,
                newest_username: 'kenji',
                newest_created_at: '2026-06-15T00:00:00Z'
            },
            mangas: {
                total: 12,
                synced: 10,
                in_progress: 1,
                dropped: 1,
                total_chapters: 340,
                total_pages: 9001,
                newest_title: 'Sakamoto Days',
                newest_seen_at: '2026-06-16T00:00:00Z'
            },
            analysis: { analyzed_pages: 5760, total_pages: 9001 }
        };
        fetchSpy.mockResolvedValueOnce(ok(fixture));
        const s = await getOverviewStats();
        expect(s.users.total).toBe(7);
        expect(s.users.admins).toBe(2);
        expect(s.mangas.synced).toBe(10);
        expect(s.mangas.total_pages).toBe(9001);
        expect(s.analysis.analyzed_pages).toBe(5760);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/overview$/);
    });

    // ---- force resync ----

    it('resyncManga POSTs to /v1/admin/mangas/{id}/resync and returns the envelope', async () => {
        const resp = {
            manga: {
                id: 'm-1',
                title: 'T',
                status: 'ongoing',
                alt_titles: [],
                description: null,
                cover_image_path: 'mangas/m-1/cover.jpg',
                created_at: '2026-01-01T00:00:00Z',
                updated_at: '2026-01-02T00:00:00Z',
                authors: [],
                genres: [],
                tags: []
            },
            metadata_status: 'updated',
            cover_fetched: true
        };
        fetchSpy.mockResolvedValueOnce(ok(resp));
        const got = await resyncManga('m-1');
        expect(got.metadata_status).toBe('updated');
        expect(got.cover_fetched).toBe(true);
        expect(got.manga.id).toBe('m-1');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/resync$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
    });

    it('resyncManga surfaces 503 service_unavailable when the daemon is off', async () => {
        fetchSpy.mockResolvedValueOnce(
            envelope(503, 'service_unavailable', 'crawler daemon is disabled')
        );
        await expect(resyncManga('m-1')).rejects.toMatchObject({
            status: 503,
            code: 'service_unavailable'
        });
    });

    it('resyncChapter POSTs to /v1/admin/chapters/{id}/resync and returns the envelope', async () => {
        const resp = {
            chapter: {
                id: 'c-1',
                manga_id: 'm-1',
                number: 1,
                title: 'Foo',
                page_count: 7,
                created_at: '2026-01-01T00:00:00Z'
            },
            outcome: 'fetched',
            pages: 7
        };
        fetchSpy.mockResolvedValueOnce(ok(resp));
        const got = await resyncChapter('c-1');
        expect(got.outcome).toBe('fetched');
        expect(got.pages).toBe(7);
        expect(got.chapter.page_count).toBe(7);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/chapters\/c-1\/resync$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
    });

    it('resyncChapter handles the "skipped" outcome envelope', async () => {
        const resp = {
            chapter: {
                id: 'c-1',
                manga_id: 'm-1',
                number: 1,
                title: null,
                page_count: 7,
                created_at: '2026-01-01T00:00:00Z'
            },
            outcome: 'skipped',
            pages: null
        };
        fetchSpy.mockResolvedValueOnce(ok(resp));
        const got = await resyncChapter('c-1');
        expect(got.outcome).toBe('skipped');
        expect(got.pages).toBeNull();
    });
});

describe('admin crawler api client', () => {
    let fetchSpy: MockInstance<typeof globalThis.fetch>;
    beforeEach(() => {
        fetchSpy = vi.spyOn(globalThis, 'fetch');
    });
    afterEach(() => {
        vi.restoreAllMocks();
    });

    const statusFixture = {
        daemon: 'running',
        phase: { state: 'fetching_metadata', index: 3, total: 10, title: 'One Piece' },
        worker_count: 2,
        active_chapters: [
            {
                manga_id: 'm-1',
                manga_title: 'Bleach',
                chapter_id: 'c-1',
                chapter_number: 12,
                pages_done: 4,
                pages_total: 20
            }
        ],
        current_cover: { manga_id: 'm-2', manga_title: 'Naruto' },
        covers_queued: 7,
        last_pass: { at: null, discovered: 0, upserted: 0, covers_fetched: 0, mangas_failed: 0 },
        session: { expired: false, configured: true },
        browser: 'healthy',
        queue: { pending: 2, running: 1, dead: 4 }
    };

    it('crawlerStatusStreamUrl points at the SSE endpoint under the API base', () => {
        expect(crawlerStatusStreamUrl()).toMatch(/\/v1\/admin\/crawler\/stream$/);
    });

    it('getCrawlerStatus GETs /v1/admin/crawler with live chapter/cover fields', async () => {
        fetchSpy.mockResolvedValueOnce(ok(statusFixture));
        const s = await getCrawlerStatus();
        expect(s.queue.dead).toBe(4);
        expect(s.phase?.state).toBe('fetching_metadata');
        expect(s.active_chapters[0].pages_done).toBe(4);
        expect(s.active_chapters[0].pages_total).toBe(20);
        expect(s.current_cover?.manga_title).toBe('Naruto');
        expect(s.covers_queued).toBe(7);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/crawler$/);
    });

    it('listActiveJobs GETs /v1/admin/crawler/active-jobs with search', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 20, offset: 0, total: 0 } })
        );
        await listActiveJobs({ search: 'bleach' });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/crawler\/active-jobs\?/);
        expect(url).toContain('search=bleach');
    });

    it('listMissingCovers GETs /v1/admin/crawler/covers', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [{ manga_id: 'm-1', manga_title: 'X' }], page: { limit: 20, offset: 0, total: 1 } })
        );
        const r = await listMissingCovers();
        expect(r.items[0].manga_title).toBe('X');
        expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/covers$/);
    });

    it('listAuditLog GETs /v1/admin/audit with the paged envelope', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                items: [
                    {
                        id: 'a-1',
                        actor_user_id: 'u-1',
                        actor_username: 'alice',
                        action: 'crawler_run',
                        target_kind: 'crawler',
                        target_id: null,
                        payload: {},
                        at: '2026-06-01T00:00:00Z'
                    }
                ],
                page: { limit: 25, offset: 0, total: 1 }
            })
        );
        const r = await listAuditLog({ limit: 25 });
        expect(r.items[0].action).toBe('crawler_run');
        expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/audit\?limit=25$/);
    });

    it('listAuditLog forwards action / target_kind / days filters', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 25, offset: 0, total: 0 } })
        );
        await listAuditLog({ action: 'manga_resync', targetKind: 'manga', days: 7 });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('action=manga_resync');
        expect(url).toContain('target_kind=manga');
        expect(url).toContain('days=7');
    });

    const healthFixture = {
        status: 'ok' as const,
        checks: [
            {
                id: 'memory',
                label: 'Memory usage',
                status: 'ok' as const,
                value: '48%',
                threshold: '90%',
                hint: null,
                action_href: null
            }
        ],
        thresholds: {
            disk_pct: 90,
            mem_pct: 90,
            dead_jobs_max: 100,
            crawl_fail_pct: 20,
            analysis_fail_pct: 20,
            cron_freshness_hours: 26,
            missing_covers_max: 500
        }
    };

    it('getCrawlerMetricsSeries GETs the crawler series with days', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ buckets: [] }));
        await getCrawlerMetricsSeries(7);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/crawler\/metrics\/series\?days=7$/);
    });

    it('getAnalysisMetricsSeries omits days=0 (all-time)', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ buckets: [] }));
        await getAnalysisMetricsSeries(0);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/analysis\/metrics\/series$/);
    });

    it('getHealth GETs /v1/admin/health', async () => {
        fetchSpy.mockResolvedValueOnce(ok(healthFixture));
        const r = await getHealth();
        expect(r.status).toBe('ok');
        expect(r.checks[0].id).toBe('memory');
        expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/health$/);
    });

    it('updateHealthThresholds PUTs the thresholds body', async () => {
        fetchSpy.mockResolvedValueOnce(ok(healthFixture));
        await updateHealthThresholds(healthFixture.thresholds);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/health\/thresholds$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('PUT');
        expect(JSON.parse(init.body as string).dead_jobs_max).toBe(100);
    });

    it('runCrawlerPass POSTs /v1/admin/crawler/run', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ started: true }));
        const r = await runCrawlerPass();
        expect(r.started).toBe(true);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/run$/);
    });

    it('reconcileCrawler POSTs /v1/admin/crawler/reconcile', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ started: true }));
        const r = await reconcileCrawler();
        expect(r.started).toBe(true);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/reconcile$/);
    });

    it('restartCrawlerBrowser POSTs the restart endpoint', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ ok: true, error: null }));
        const r = await restartCrawlerBrowser();
        expect(r.ok).toBe(true);
        expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/browser\/restart$/);
    });

    it('updateCrawlerSession POSTs the phpsessid body', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ valid: true, error: null }));
        const r = await updateCrawlerSession('abc123');
        expect(r.valid).toBe(true);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(JSON.parse(init.body as string)).toEqual({ phpsessid: 'abc123' });
    });

    it('clearCrawlerSessionExpired POSTs clear-expired', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ cleared: true }));
        const r = await clearCrawlerSessionExpired();
        expect(r.cleared).toBe(true);
        expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/session\/clear-expired$/);
    });

    it('listDeadJobs forwards search + pagination', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 20, offset: 20, total: 0 } })
        );
        await listDeadJobs({ search: 'naruto', limit: 20, offset: 20 });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('search=naruto');
        expect(url).toContain('offset=20');
    });

    it('requeueDeadJobs POSTs the scope body', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ requeued: 3 }));
        const r = await requeueDeadJobs({ scope: 'manga', manga_id: 'm-9' });
        expect(r.requeued).toBe(3);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(JSON.parse(init.body as string)).toEqual({ scope: 'manga', manga_id: 'm-9' });
    });

    it('requeueDeadJobs serialises chapter scope verbatim', async () => {
        // F7 — the per-chapter requeue button in /admin/mangas sends this
        // exact shape; pin it so a future rename of `chapter_id` doesn't
        // silently break the inline button.
        fetchSpy.mockResolvedValueOnce(ok({ requeued: 1 }));
        const r = await requeueDeadJobs({ scope: 'chapter', chapter_id: 'c-7' });
        expect(r.requeued).toBe(1);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(JSON.parse(init.body as string)).toEqual({
            scope: 'chapter',
            chapter_id: 'c-7'
        });
    });

    it('requeueDeadJobs serialises job + all (with confirm) scopes', async () => {
        // Round out the variant coverage: { scope: 'job', job_id } and
        // { scope: 'all', confirm: true }. The audit (S1) requires the
        // confirm flag on the wire for scope=all; this pins it so a
        // future refactor can't drop it.
        fetchSpy.mockResolvedValueOnce(ok({ requeued: 1 }));
        await requeueDeadJobs({ scope: 'job', job_id: 'j-1' });
        expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
            scope: 'job',
            job_id: 'j-1'
        });
        fetchSpy.mockResolvedValueOnce(ok({ requeued: 99 }));
        await requeueDeadJobs({ scope: 'all', confirm: true });
        expect(JSON.parse(fetchSpy.mock.calls[1][1]!.body as string)).toEqual({
            scope: 'all',
            confirm: true
        });
    });

    it('surfaces a 503 as ApiError', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled'));
        await expect(runCrawlerPass()).rejects.toMatchObject({ status: 503 });
    });

    it('reenqueueAnalysis defaults to whole-library, exclude-analyzed', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ enqueued: 42 }));
        const r = await reenqueueAnalysis();
        expect(r.enqueued).toBe(42);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/analysis\/reenqueue$/);
        expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
            only_unanalyzed: true
        });
    });

    it('reenqueueAnalysis scopes to a manga and can include analyzed', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ enqueued: 7 }));
        await reenqueueAnalysis({ mangaId: 'm1', onlyUnanalyzed: false });
        expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
            only_unanalyzed: false,
            manga_id: 'm1'
        });
    });

    it('reenqueueAnalysis scopes to a chapter', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ enqueued: 3 }));
        await reenqueueAnalysis({ chapterId: 'c1' });
        expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
            only_unanalyzed: true,
            chapter_id: 'c1'
        });
    });

    it('analyzePage posts to the force-analyze endpoint', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ enqueued: true }));
        const r = await analyzePage('p1');
        expect(r.enqueued).toBe(true);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/);
        expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
    });

    it('getAnalysisMangaCoverage passes search + pagination', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 25, offset: 0, total: 0 } })
        );
        await getAnalysisMangaCoverage({ search: 'ber', limit: 25, offset: 50 });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('/v1/admin/analysis/mangas?');
        expect(url).toContain('search=ber');
        expect(url).toContain('limit=25');
        expect(url).toContain('offset=50');
    });

    it('getAnalysisMangaCoverage forwards the AbortSignal to fetch', async () => {
        // The dashboard's debounce-race fix (0.87.9) relies on this knob
        // landing on `init.signal`. Without it, a slow first request
        // can still overwrite a faster second one because `request()`
        // builds a fresh fetch with no cancellation hook.
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
        );
        const ctrl = new AbortController();
        await getAnalysisMangaCoverage(
            { limit: 50 },
            { signal: ctrl.signal }
        );
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.signal).toBe(ctrl.signal);
    });

    it('getAnalysisMangaCoverage propagates an abort rejection', async () => {
        // Production Node fetch rejects synchronously with AbortError
        // when called with an already-aborted signal. Mirror that
        // (instead of wiring our own listener and asserting the
        // rejection we self-fire) so the test exercises the contract
        // the consumer relies on — the CancellableLoader at
        // $lib/util/cancellable.ts treats AbortError as `null` and
        // skips the state update.
        const ctrl = new AbortController();
        ctrl.abort();
        fetchSpy.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
        await expect(
            getAnalysisMangaCoverage({ limit: 25 }, { signal: ctrl.signal })
        ).rejects.toMatchObject({ name: 'AbortError' });
    });

    it('getAnalysisChapterCoverage unwraps items', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [{ chapter_id: 'c1', number: 1, title: null, total_pages: 2, analyzed_pages: 1 }] })
        );
        const items = await getAnalysisChapterCoverage('m1');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/analysis\/mangas\/m1\/chapters$/);
        expect(items).toHaveLength(1);
    });

    it('getAnalysisChapterPages unwraps items', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [{ page_id: 'p1', page_number: 1, status: 'done' }] })
        );
        const items = await getAnalysisChapterPages('c1');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/analysis\/chapters\/c1\/pages$/);
        expect(items[0].status).toBe('done');
    });

    it('getAnalysisPageDetail hits the page endpoint', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ page_id: 'p1', status: 'none', ocr: [], tags: [], content_warnings: [] })
        );
        const d = await getAnalysisPageDetail('p1');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/);
        expect(d.status).toBe('none');
    });

    it('analysisStatusStreamUrl points at the SSE endpoint', () => {
        expect(analysisStatusStreamUrl()).toMatch(
            /\/v1\/admin\/analysis\/status\/stream$/
        );
    });

    // ---- storage usage ----

    it('getStorageStats GETs /v1/admin/storage and parses the envelope', async () => {
        const fixture = {
            total_bytes: 700,
            covers_bytes: 100,
            chapters_bytes: 600,
            disk_total_bytes: 1_000_000,
            ratio_of_disk: 0.0007,
            avg_image_bytes: 233.3,
            avg_cover_bytes: 100,
            avg_chapter_page_bytes: 300,
            top_mangas: [{ id: 'm-1', title: 'Big', total_bytes: 650 }],
            top_chapters: [
                { id: 'c-1', manga_id: 'm-1', number: 1, title: null, total_bytes: 600 }
            ],
            unmeasured_pages: 4,
            unmeasured_covers: 1
        };
        fetchSpy.mockResolvedValueOnce(ok(fixture));
        const s = await getStorageStats();
        expect(s.total_bytes).toBe(700);
        expect(s.top_mangas[0].title).toBe('Big');
        expect(s.top_chapters[0].total_bytes).toBe(600);
        expect(s.unmeasured_pages).toBe(4);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/storage$/);
    });

    it('getStorageStats keeps disk total + ratio null for a non-local store', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                total_bytes: 0,
                covers_bytes: 0,
                chapters_bytes: 0,
                disk_total_bytes: null,
                ratio_of_disk: null,
                avg_image_bytes: null,
                avg_cover_bytes: null,
                avg_chapter_page_bytes: null,
                top_mangas: [],
                top_chapters: [],
                unmeasured_pages: 0,
                unmeasured_covers: 0
            })
        );
        const s = await getStorageStats();
        expect(s.disk_total_bytes).toBeNull();
        expect(s.ratio_of_disk).toBeNull();
    });

    it('backfillStorage POSTs /v1/admin/storage/backfill and returns counts', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ pages: 3, covers: 1, missing: 0, errored: 0, more_remaining: false })
        );
        const r = await backfillStorage();
        expect(r).toEqual({ pages: 3, covers: 1, missing: 0, errored: 0, more_remaining: false });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/storage\/backfill$/);
        expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
    });

    // ---- job history ----

    it('listCrawlerJobHistory forwards state/kind/search/limit/offset and parses items', async () => {
        const row = {
            id: 'j-1',
            state: 'done',
            kind: 'analyze_page',
            manga_id: 'm-1',
            manga_title: 'Berserk',
            chapter_id: 'c-1',
            chapter_number: 12,
            page_number: 7,
            source_key: null,
            attempts: 1,
            max_attempts: 5,
            last_error: null,
            updated_at: '2026-06-15T00:00:00Z'
        };
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [row], page: { limit: 50, offset: 0, total: 1 } })
        );
        const page = await listCrawlerJobHistory({
            state: 'done',
            kind: 'analyze_page',
            search: 'Ber',
            offset: 10
        });
        expect(page.items[0]).toEqual(row);
        expect(page.page.total).toBe(1);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('/v1/admin/crawler/history?');
        expect(url).toContain('state=done');
        expect(url).toContain('kind=analyze_page');
        expect(url).toContain('search=Ber');
        expect(url).toContain('offset=10');
    });

    it('listCrawlerJobHistory omits unset filters', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
        );
        await listCrawlerJobHistory();
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/crawler\/history$/);
    });

    it('listAnalysisHistory forwards status/nsfw/search and parses items', async () => {
        const row = {
            page_id: 'p-1',
            page_number: 3,
            chapter_id: 'c-1',
            chapter_number: 700,
            manga_id: 'm-1',
            manga_title: 'Naruto',
            status: 'failed',
            is_nsfw: false,
            model: null,
            error: 'boom',
            analyzed_at: '2026-06-15T00:00:00Z'
        };
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [row], page: { limit: 25, offset: 0, total: 1 } })
        );
        const page = await listAnalysisHistory({ status: 'failed', nsfw: true, search: 'Nar' });
        expect(page.items[0]).toEqual(row);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('/v1/admin/analysis/history?');
        expect(url).toContain('status=failed');
        expect(url).toContain('nsfw=true');
        expect(url).toContain('search=Nar');
    });

    it('listAnalysisHistory omits nsfw when false', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 25, offset: 0, total: 0 } })
        );
        await listAnalysisHistory({ status: 'done', nsfw: false });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('status=done');
        expect(url).not.toContain('nsfw');
    });

    // ---- operation metrics ----

    it('getCrawlerMetrics omits days when 0 and parses the summary', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                summary: [
                    { op: 'chapter', avg_ms: 7000, n: 2, ok: 1, failed: 1, avg_items: 15 }
                ]
            })
        );
        const r = await getCrawlerMetrics(0);
        expect(r.summary[0].avg_ms).toBe(7000);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/admin\/crawler\/metrics$/);
    });

    it('getCrawlerMetrics forwards a positive days window', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ summary: [] }));
        await getCrawlerMetrics(7);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('days=7');
    });

    it('listCrawlerOps forwards op/outcome/days and parses the page', async () => {
        const row = {
            id: 'o-1',
            op: 'chapter',
            manga_id: 'm-1',
            manga_title: 'Berserk',
            chapter_id: 'c-1',
            chapter_number: 12,
            outcome: 'ok',
            duration_ms: 6100,
            items: 20,
            error: null,
            finished_at: '2026-06-15T00:00:00Z'
        };
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [row], page: { limit: 50, offset: 0, total: 1 } })
        );
        const page = await listCrawlerOps({ op: 'chapter', outcome: 'ok', days: 30 });
        expect(page.items[0]).toEqual(row);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('/v1/admin/crawler/metrics/ops?');
        expect(url).toContain('op=chapter');
        expect(url).toContain('outcome=ok');
        expect(url).toContain('days=30');
    });

    it('getAnalysisMetrics parses aggregate + by-model', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                n: 4,
                ok: 3,
                failed: 1,
                avg_ms: 2500,
                by_model: [{ model: 'qwen', avg_ms: 3000, n: 2 }]
            })
        );
        const m = await getAnalysisMetrics(7);
        expect(m.avg_ms).toBe(2500);
        expect(m.by_model[0].model).toBe('qwen');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('/v1/admin/analysis/metrics?days=7');
    });
});
