// Admin-only API client. Every endpoint here is guarded by
// RequireAdmin on the backend (session cookie only — bearer tokens
// won't reach these routes). 403s thrown here propagate up to the
// /admin layout, which renders the framework error page.

import { request, apiUrl, type Page } from './client';
import type { User } from './auth';
import type { MangaDetail } from './mangas';
import type { Chapter } from './chapters';
import type { ContentWarning } from './page_tags';

// ---- users -----------------------------------------------------------------

export type AdminUsersPage = {
    items: User[];
    page: Page;
};

export type ListAdminUsersOptions = {
    search?: string;
    limit?: number;
    offset?: number;
};

export async function listAdminUsers(
    opts: ListAdminUsersOptions = {}
): Promise<AdminUsersPage> {
    const params = new URLSearchParams();
    if (opts.search) params.set('search', opts.search);
    if (opts.limit != null) params.set('limit', String(opts.limit));
    if (opts.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<AdminUsersPage>(`/v1/admin/users${qs ? `?${qs}` : ''}`);
}

export async function deleteAdminUser(id: string): Promise<void> {
    await request<void>(`/v1/admin/users/${encodeURIComponent(id)}`, {
        method: 'DELETE'
    });
}

export async function setUserAdmin(id: string, isAdmin: boolean): Promise<User> {
    return request<User>(`/v1/admin/users/${encodeURIComponent(id)}`, {
        method: 'PATCH',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ is_admin: isAdmin })
    });
}

export type CreateAdminUserInput = {
    username: string;
    password: string;
    is_admin?: boolean;
};

/** POST /v1/admin/users — admin-initiated account creation. Works
 *  regardless of the ALLOW_SELF_REGISTER toggle, since the entire
 *  point is for an admin to enroll someone when self-register is off. */
export async function createAdminUser(input: CreateAdminUserInput): Promise<User> {
    return request<User>('/v1/admin/users', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(input)
    });
}

// ---- mangas / chapters with sync state -------------------------------------

export type MangaSyncState = 'in_progress' | 'dropped' | 'synced';

export type AdminMangaRow = {
    id: string;
    title: string;
    status: string;
    cover_image_path: string | null;
    created_at: string;
    updated_at: string;
    sync_state: MangaSyncState;
    chapter_count: number;
    latest_seen_at: string | null;
};

export type AdminMangasPage = {
    items: AdminMangaRow[];
    page: Page;
};

export type ListAdminMangasOptions = {
    search?: string;
    syncState?: MangaSyncState;
    limit?: number;
    offset?: number;
};

export async function listAdminMangas(
    opts: ListAdminMangasOptions = {}
): Promise<AdminMangasPage> {
    const params = new URLSearchParams();
    if (opts.search) params.set('search', opts.search);
    if (opts.syncState) params.set('sync_state', opts.syncState);
    if (opts.limit != null) params.set('limit', String(opts.limit));
    if (opts.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<AdminMangasPage>(`/v1/admin/mangas${qs ? `?${qs}` : ''}`);
}

export type ChapterSyncState =
    | 'downloading'
    | 'dropped'
    | 'failed'
    | 'not_downloaded'
    | 'synced';

export type AdminChapterRow = {
    id: string;
    manga_id: string;
    number: number;
    title: string | null;
    page_count: number;
    created_at: string;
    sync_state: ChapterSyncState;
    latest_seen_at: string | null;
};

export type AdminChaptersPage = {
    items: AdminChapterRow[];
    page: Page;
};

export type ListAdminChaptersOptions = {
    limit?: number;
    offset?: number;
};

export async function listAdminChapters(
    mangaId: string,
    opts: ListAdminChaptersOptions = {}
): Promise<AdminChaptersPage> {
    const params = new URLSearchParams();
    if (opts.limit != null) params.set('limit', String(opts.limit));
    if (opts.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<AdminChaptersPage>(
        `/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters${qs ? `?${qs}` : ''}`
    );
}

// ---- system ----------------------------------------------------------------

export type DiskStats = {
    total_bytes: number;
    used_bytes: number;
    free_bytes: number;
    percent_used: number;
};

export type MemoryStats = {
    total_bytes: number;
    used_bytes: number;
    percent_used: number;
};

export type LoadAvg = {
    one: number;
    five: number;
    fifteen: number;
};

export type CpuStats = {
    percent_used: number;
    load_avg: LoadAvg;
    /** Per-core usage percentages, one entry per logical core. */
    per_core: number[];
};

export type TempStat = {
    label: string;
    celsius: number;
    max_celsius: number | null;
    critical_celsius: number | null;
};

export type Alert = {
    level: 'warning';
    message: string;
};

export type SystemStats = {
    disk: DiskStats | null;
    memory: MemoryStats;
    cpu: CpuStats;
    /** Hardware temperature sensors. Empty when none are exposed (e.g.
     * inside a container without `/sys` access). */
    temperatures: TempStat[];
    alerts: Alert[];
};

export async function getSystemStats(): Promise<SystemStats> {
    return request<SystemStats>('/v1/admin/system');
}

// ---- overview aggregates ---------------------------------------------------

export type UsersOverview = {
    total: number;
    admins: number;
    newest_username: string | null;
    newest_created_at: string | null;
};

export type MangasOverview = {
    total: number;
    synced: number;
    in_progress: number;
    dropped: number;
    total_chapters: number;
    total_pages: number;
    newest_title: string | null;
    newest_seen_at: string | null;
};

export type AnalysisOverview = {
    analyzed_pages: number;
    total_pages: number;
};

export type OverviewStats = {
    users: UsersOverview;
    mangas: MangasOverview;
    analysis: AnalysisOverview;
};

export async function getOverviewStats(): Promise<OverviewStats> {
    return request<OverviewStats>('/v1/admin/overview');
}

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

export type TopManga = {
    id: string;
    title: string;
    total_bytes: number;
};

export type TopChapter = {
    id: string;
    manga_id: string;
    number: number;
    title: string | null;
    total_bytes: number;
};

export type StorageStats = {
    total_bytes: number;
    covers_bytes: number;
    chapters_bytes: number;
    disk_total_bytes: number | null;
    ratio_of_disk: number | null;
    avg_image_bytes: number | null;
    avg_cover_bytes: number | null;
    avg_chapter_page_bytes: number | null;
    top_mangas: TopManga[];
    top_chapters: TopChapter[];
    /** Pages still awaiting a size backfill. When > 0 the figures above are
     * partial and the leaderboards omit not-yet-measured content. */
    unmeasured_pages: number;
    /** Cover blobs still awaiting a size backfill. */
    unmeasured_covers: number;
};

export type StorageBackfillResult = {
    pages: number;
    covers: number;
    /** Blobs storage reports as absent (orphaned). */
    missing: number;
    /** Blobs that errored for another reason (bad key, transient IO) — retryable. */
    errored: number;
    /** `true` when a per-run cap was hit before the backlog drained; run again. */
    more_remaining: boolean;
};

export async function getStorageStats(): Promise<StorageStats> {
    return request<StorageStats>('/v1/admin/storage');
}

export async function backfillStorage(): Promise<StorageBackfillResult> {
    return request<StorageBackfillResult>('/v1/admin/storage/backfill', {
        method: 'POST'
    });
}

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

export type MangaResyncResponse = {
    manga: MangaDetail;
    metadata_status: 'new' | 'updated' | 'unchanged';
    cover_fetched: boolean;
};

export type ChapterResyncResponse = {
    chapter: Chapter;
    outcome: 'fetched' | 'skipped';
    /** Page count when `outcome === 'fetched'`; null when skipped. */
    pages: number | null;
};

/** POST /v1/admin/mangas/:id/resync — refetches metadata + cover from
 *  the manga's live crawler source. Long-running (one HTTP request per
 *  Chromium nav + image download), so the UI should disable the trigger
 *  and surface progress. */
export async function resyncManga(id: string): Promise<MangaResyncResponse> {
    return request<MangaResyncResponse>(
        `/v1/admin/mangas/${encodeURIComponent(id)}/resync`,
        { method: 'POST' }
    );
}

/** POST /v1/admin/chapters/:id/resync — force-refetches a chapter's
 *  pages even if `page_count > 0`. Same long-running caveat as
 *  `resyncManga`. */
export async function resyncChapter(id: string): Promise<ChapterResyncResponse> {
    return request<ChapterResyncResponse>(
        `/v1/admin/chapters/${encodeURIComponent(id)}/resync`,
        { method: 'POST' }
    );
}

// ---- crawler observability + control ---------------------------------------

/** Current daemon activity. Discriminated on `state`. */
export type CrawlerPhase =
    | { state: 'idle'; next_fire: string | null }
    | { state: 'walking_list' }
    | { state: 'fetching_metadata'; index: number; total: number | null; title: string }
    | { state: 'cover_backfill'; index: number; total: number }
    | { state: 'reconciling'; walked: number; enqueued: number };

/** A chapter being crawled right now, with a live page count. */
export type ActiveChapter = {
    manga_id: string;
    manga_title: string;
    chapter_id: string;
    chapter_number: number;
    pages_done: number;
    pages_total: number | null;
};

export type CrawlerLastPass = {
    at: string | null;
    discovered: number;
    upserted: number;
    covers_fetched: number;
    mangas_failed: number;
};

export type CrawlerStatus = {
    daemon: 'running' | 'disabled';
    phase: CrawlerPhase | null;
    worker_count: number;
    active_chapters: ActiveChapter[];
    current_cover: { manga_id: string; manga_title: string } | null;
    covers_queued: number;
    last_pass: CrawlerLastPass;
    session: { expired: boolean; configured: boolean };
    browser: 'healthy' | 'draining' | 'restarting' | 'down';
    queue: { pending: number; running: number; dead: number };
};

export async function getCrawlerStatus(): Promise<CrawlerStatus> {
    return request<CrawlerStatus>('/v1/admin/crawler');
}

/** URL of the Server-Sent Events live-status stream. Open with
 *  `new EventSource(...)` while the crawler page is mounted and close it on
 *  navigate-away so the subscription is scoped to the active page. Each
 *  message is a named `status` event whose `data` is a {@link CrawlerStatus}. */
export function crawlerStatusStreamUrl(): string {
    return apiUrl('/v1/admin/crawler/stream');
}

/** POST /v1/admin/crawler/run — trigger an out-of-cycle metadata pass. */
export async function runCrawlerPass(): Promise<{ started: boolean }> {
    return request('/v1/admin/crawler/run', { method: 'POST' });
}

/** POST /v1/admin/crawler/reconcile — full list-only walk that enqueues
 *  mangas missing from the DB as sync_manga jobs. */
export async function reconcileCrawler(): Promise<{ started: boolean }> {
    return request('/v1/admin/crawler/reconcile', { method: 'POST' });
}

/** POST /v1/admin/crawler/browser/restart — coordinated Chromium restart. */
export async function restartCrawlerBrowser(): Promise<{ ok: boolean; error: string | null }> {
    return request('/v1/admin/crawler/browser/restart', { method: 'POST' });
}

/** POST /v1/admin/crawler/session — refresh PHPSESSID and re-probe. */
export async function updateCrawlerSession(
    phpsessid: string
): Promise<{ valid: boolean; error: string | null }> {
    return request('/v1/admin/crawler/session', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ phpsessid })
    });
}

/** POST /v1/admin/crawler/session/clear-expired — resume idled workers. */
export async function clearCrawlerSessionExpired(): Promise<{ cleared: boolean }> {
    return request('/v1/admin/crawler/session/clear-expired', { method: 'POST' });
}

export type DeadJob = {
    id: string;
    kind: string;
    chapter_id: string | null;
    manga_id: string | null;
    manga_title: string | null;
    chapter_number: number | null;
    /** Payload title (fallback label for sync_manga jobs with no manga row). */
    payload_title: string | null;
    /** Source detail URL from the payload (sync_manga). */
    source_url: string | null;
    /** Source-native key from the payload (sync_manga). */
    source_key: string | null;
    attempts: number;
    max_attempts: number;
    last_error: string | null;
    updated_at: string;
};

export type DeadJobsPage = { items: DeadJob[]; page: Page };

export async function listDeadJobs(
    opts?: {
        search?: string;
        limit?: number;
        offset?: number;
    },
    init?: RequestInit
): Promise<DeadJobsPage> {
    const params = new URLSearchParams();
    if (opts?.search) params.set('search', opts.search);
    if (opts?.limit != null) params.set('limit', String(opts.limit));
    if (opts?.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<DeadJobsPage>(
        `/v1/admin/crawler/dead-jobs${qs ? `?${qs}` : ''}`,
        init
    );
}

/** Requeue scope: all dead jobs, one manga's, one chapter's, or a single job.
 *  `scope: 'all'` requires an explicit `confirm: true` so a careless
 *  click (or CSRF bait) can't flip the entire dead pile. */
export type RequeueScope =
    | { scope: 'all'; confirm: true }
    | { scope: 'manga'; manga_id: string }
    | { scope: 'chapter'; chapter_id: string }
    | { scope: 'job'; job_id: string };

export async function requeueDeadJobs(scope: RequeueScope): Promise<{ requeued: number }> {
    return request('/v1/admin/crawler/dead-jobs/requeue', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(scope)
    });
}

/** A queued/running chapter-content job (which chapters are queued). */
export type ActiveJob = {
    id: string;
    chapter_id: string | null;
    manga_id: string | null;
    manga_title: string | null;
    chapter_number: number | null;
    state: 'pending' | 'running';
    attempts: number;
    max_attempts: number;
    updated_at: string;
};

export type ActiveJobsPage = { items: ActiveJob[]; page: Page };

/** GET /v1/admin/crawler/active-jobs — which chapters of which mangas are
 *  queued or running now. */
export async function listActiveJobs(
    opts?: {
        search?: string;
        limit?: number;
        offset?: number;
    },
    init?: RequestInit
): Promise<ActiveJobsPage> {
    const params = new URLSearchParams();
    if (opts?.search) params.set('search', opts.search);
    if (opts?.limit != null) params.set('limit', String(opts.limit));
    if (opts?.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<ActiveJobsPage>(
        `/v1/admin/crawler/active-jobs${qs ? `?${qs}` : ''}`,
        init
    );
}

/** Queue states a crawler job can be in. */
export type CrawlerJobState = 'pending' | 'running' | 'done' | 'dead';

/** Job kinds the crawler/analysis queue carries. */
export type CrawlerJobKind =
    | 'sync_manga'
    | 'sync_chapter_list'
    | 'sync_chapter_content'
    | 'analyze_page';

/** One row in the crawler job-history table — any state/kind, resolved to
 *  its manga/chapter/page context (best-effort `null`s for kinds that don't
 *  carry that reference, e.g. a bootstrap `sync_manga`). */
export type CrawlerHistoryRow = {
    id: string;
    state: CrawlerJobState;
    kind: CrawlerJobKind | null;
    manga_id: string | null;
    manga_title: string | null;
    chapter_id: string | null;
    chapter_number: number | null;
    page_number: number | null;
    source_key: string | null;
    /** Payload title (fallback label for sync_manga jobs with no manga row). */
    payload_title: string | null;
    /** Source detail URL from the payload (sync_manga). */
    source_url: string | null;
    attempts: number;
    max_attempts: number;
    last_error: string | null;
    updated_at: string;
    /** Recorded duration for a chapter / analyze job; `null` otherwise. */
    duration_ms: number | null;
};

export type CrawlerHistoryPage = { items: CrawlerHistoryRow[]; page: Page };

/** GET /v1/admin/crawler/history — unified, searchable/filterable job log.
 *  History depth is bounded by the done-job reaper (recent window); `dead`
 *  jobs persist until requeued. */
export async function listCrawlerJobHistory(
    opts?: {
        state?: CrawlerJobState;
        kind?: CrawlerJobKind;
        search?: string;
        limit?: number;
        offset?: number;
    },
    init?: RequestInit
): Promise<CrawlerHistoryPage> {
    const params = new URLSearchParams();
    if (opts?.state) params.set('state', opts.state);
    if (opts?.kind) params.set('kind', opts.kind);
    if (opts?.search) params.set('search', opts.search);
    if (opts?.limit != null) params.set('limit', String(opts.limit));
    if (opts?.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<CrawlerHistoryPage>(
        `/v1/admin/crawler/history${qs ? `?${qs}` : ''}`,
        init
    );
}

// ---- operation metrics (durations / averages) ------------------------------

/** Crawl operation kinds timed in `crawl_metrics`. */
export type CrawlOp = 'manga_list' | 'manga_detail' | 'manga_cover' | 'chapter';

/** Per-op average roll-up over the selected window. */
export type OpSummary = {
    op: CrawlOp;
    avg_ms: number | null;
    n: number;
    ok: number;
    failed: number;
    avg_items: number | null;
};

/** One timed operation in the recent-ops log. */
export type OpRow = {
    id: string;
    op: CrawlOp;
    manga_id: string | null;
    manga_title: string | null;
    chapter_id: string | null;
    chapter_number: number | null;
    outcome: 'ok' | 'failed';
    duration_ms: number;
    items: number | null;
    error: string | null;
    finished_at: string;
};

export type CrawlerOpsPage = { items: OpRow[]; page: Page };

/** GET /v1/admin/crawler/metrics — per-type average durations + success.
 *  `days` windows the rows (0/omitted = all time). */
export async function getCrawlerMetrics(days = 0): Promise<{ summary: OpSummary[] }> {
    const qs = days > 0 ? `?days=${days}` : '';
    return request<{ summary: OpSummary[] }>(`/v1/admin/crawler/metrics${qs}`);
}

/** GET /v1/admin/crawler/metrics/ops — paginated recent timed-operations log. */
export async function listCrawlerOps(
    opts?: {
        op?: CrawlOp;
        outcome?: 'ok' | 'failed';
        days?: number;
        limit?: number;
        offset?: number;
    },
    init?: RequestInit
): Promise<CrawlerOpsPage> {
    const params = new URLSearchParams();
    if (opts?.op) params.set('op', opts.op);
    if (opts?.outcome) params.set('outcome', opts.outcome);
    if (opts?.days != null && opts.days > 0) params.set('days', String(opts.days));
    if (opts?.limit != null) params.set('limit', String(opts.limit));
    if (opts?.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<CrawlerOpsPage>(
        `/v1/admin/crawler/metrics/ops${qs ? `?${qs}` : ''}`,
        init
    );
}

/** A manga queued for a cover fetch (no cover yet + a live source). */
export type MissingCover = { manga_id: string; manga_title: string };
export type MissingCoversPage = { items: MissingCover[]; page: Page };

/** GET /v1/admin/crawler/covers — which manga covers are queued. */
export async function listMissingCovers(
    opts?: {
        search?: string;
        limit?: number;
        offset?: number;
    },
    init?: RequestInit
): Promise<MissingCoversPage> {
    const params = new URLSearchParams();
    if (opts?.search) params.set('search', opts.search);
    if (opts?.limit != null) params.set('limit', String(opts.limit));
    if (opts?.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<MissingCoversPage>(
        `/v1/admin/crawler/covers${qs ? `?${qs}` : ''}`,
        init
    );
}

// ---- AI content analysis ---------------------------------------------------

/** Options for the scoped analysis re-enqueue. `mangaId` and `chapterId`
 *  are mutually exclusive; omit both to target the whole library. */
export type ReenqueueAnalysisOptions = {
    /** Skip pages that already have a completed analysis (default true).
     *  When false, in-scope pages are force re-analyzed. */
    onlyUnanalyzed?: boolean;
    mangaId?: string;
    chapterId?: string;
};

/** Bulk-enqueue `analyze_page` jobs for all / a manga's / a chapter's
 *  pages. Returns how many jobs were enqueued. Requires ANALYSIS_ENABLED
 *  on the backend (503 otherwise). */
export async function reenqueueAnalysis(
    opts: ReenqueueAnalysisOptions = {}
): Promise<{ enqueued: number }> {
    const body: Record<string, unknown> = {
        only_unanalyzed: opts.onlyUnanalyzed ?? true
    };
    if (opts.mangaId) body.manga_id = opts.mangaId;
    if (opts.chapterId) body.chapter_id = opts.chapterId;
    return request<{ enqueued: number }>('/v1/admin/analysis/reenqueue', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body)
    });
}

/** Force re-analysis of a single page (used by the reader context menu). */
export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }> {
    return request<{ enqueued: boolean }>(
        `/v1/admin/pages/${encodeURIComponent(pageId)}/analyze`,
        { method: 'POST' }
    );
}

// ---- AI content analysis: coverage & inspection ----------------------------

export type MangaCoverage = {
    manga_id: string;
    title: string;
    total_pages: number;
    analyzed_pages: number;
};

export type MangaCoveragePage = {
    items: MangaCoverage[];
    page: Page;
};

export type ChapterCoverage = {
    chapter_id: string;
    number: number;
    title: string | null;
    total_pages: number;
    analyzed_pages: number;
};

/** Per-page status in a chapter grid. */
export type PageAnalysisStatus = 'done' | 'failed' | 'queued' | 'none';

export type PageStatusItem = {
    page_id: string;
    page_number: number;
    status: PageAnalysisStatus;
};

export type OcrLine = { kind: string; text: string };

export type PageAnalysisDetail = {
    page_id: string;
    page_number: number;
    chapter_id: string;
    manga_id: string;
    /** `done` | `failed` | `none` (no analysis row yet). */
    status: 'done' | 'failed' | 'none';
    is_nsfw: boolean;
    scene_description: string | null;
    model: string | null;
    error: string | null;
    analyzed_at: string | null;
    ocr: OcrLine[];
    tags: string[];
    content_warnings: ContentWarning[];
};

/** Paginated per-manga analysis coverage (admin overview). */
export async function getAnalysisMangaCoverage(
    opts: { search?: string; limit?: number; offset?: number } = {},
    init?: { signal?: AbortSignal }
): Promise<MangaCoveragePage> {
    const params = new URLSearchParams();
    if (opts.search) params.set('search', opts.search);
    if (opts.limit != null) params.set('limit', String(opts.limit));
    if (opts.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<MangaCoveragePage>(
        `/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`,
        init?.signal ? { signal: init.signal } : undefined
    );
}

export async function getAnalysisChapterCoverage(
    mangaId: string
): Promise<ChapterCoverage[]> {
    const r = await request<{ items: ChapterCoverage[] }>(
        `/v1/admin/analysis/mangas/${encodeURIComponent(mangaId)}/chapters`
    );
    return r.items;
}

export async function getAnalysisChapterPages(
    chapterId: string
): Promise<PageStatusItem[]> {
    const r = await request<{ items: PageStatusItem[] }>(
        `/v1/admin/analysis/chapters/${encodeURIComponent(chapterId)}/pages`
    );
    return r.items;
}

export async function getAnalysisPageDetail(
    pageId: string
): Promise<PageAnalysisDetail> {
    return request<PageAnalysisDetail>(
        `/v1/admin/analysis/pages/${encodeURIComponent(pageId)}`
    );
}

/** One row in the analysis history table — a terminal (done/failed)
 *  analysis pass resolved to its page/chapter/manga context. Persists
 *  indefinitely (sourced from `page_analysis`, not the reaped job queue). */
export type AnalysisHistoryRow = {
    page_id: string;
    page_number: number;
    chapter_id: string;
    chapter_number: number;
    manga_id: string;
    manga_title: string;
    status: 'done' | 'failed';
    is_nsfw: boolean;
    model: string | null;
    error: string | null;
    analyzed_at: string | null;
    /** Wall-clock the worker spent; `null` for pre-tracking rows. */
    duration_ms: number | null;
};

export type AnalysisHistoryPage = { items: AnalysisHistoryRow[]; page: Page };

/** GET /v1/admin/analysis/history — searchable/filterable log of completed
 *  and failed page analyses, newest first. */
export async function listAnalysisHistory(
    opts?: {
        status?: 'done' | 'failed';
        nsfw?: boolean;
        search?: string;
        limit?: number;
        offset?: number;
    },
    init?: RequestInit
): Promise<AnalysisHistoryPage> {
    const params = new URLSearchParams();
    if (opts?.status) params.set('status', opts.status);
    if (opts?.nsfw) params.set('nsfw', 'true');
    if (opts?.search) params.set('search', opts.search);
    if (opts?.limit != null) params.set('limit', String(opts.limit));
    if (opts?.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<AnalysisHistoryPage>(
        `/v1/admin/analysis/history${qs ? `?${qs}` : ''}`,
        init
    );
}

/** Per-model average analysis duration. */
export type ModelDuration = { model: string | null; avg_ms: number | null; n: number };

/** Aggregate analysis timing/outcome roll-up over the selected window. */
export type AnalysisMetrics = {
    n: number;
    ok: number;
    failed: number;
    avg_ms: number | null;
    by_model: ModelDuration[];
};

/** GET /v1/admin/analysis/metrics — avg duration, success, by-model.
 *  `days` windows the rows (0/omitted = all time). */
export async function getAnalysisMetrics(days = 0): Promise<AnalysisMetrics> {
    const qs = days > 0 ? `?days=${days}` : '';
    return request<AnalysisMetrics>(`/v1/admin/analysis/metrics${qs}`);
}

/** One live analysis event from the SSE stream. */
export type AnalysisEvent =
    | {
          kind: 'enqueued';
          count: number;
          manga_id: string | null;
          chapter_id: string | null;
      }
    | {
          // `cancelled` fires when the worker drops an in-flight dispatch
          // on daemon shutdown / settings-toggle-off. The lease was
          // released, not failed — the page returns to pending. The
          // dashboard banner clears on this so it doesn't stick on the
          // cancelled page until another `started` overwrites it.
          kind: 'started' | 'completed' | 'failed' | 'cancelled';
          page_id: string;
          manga_id: string;
          manga_title: string;
          chapter_id: string;
          chapter_number: number;
          page_number: number;
      };

/** URL of the live analysis SSE stream. Open with `new EventSource(...)`
 *  while the admin Analysis page is mounted and close it on navigate-away.
 *  Each message is a named `analysis` event whose `data` is an
 *  {@link AnalysisEvent}; a `lagged` event signals dropped frames. */
export function analysisStatusStreamUrl(): string {
    return apiUrl('/v1/admin/analysis/status/stream');
}

// ---- runtime settings (crawler + analysis) ---------------------------------

/** Operationally-safe, admin-editable crawler settings. Host/infra and
 *  session/secret fields are managed via environment and surfaced read-only
 *  in {@link CrawlerEnvOnly}. */
export type CrawlerSettings = {
    daemon_enabled: boolean;
    daily_at: string; // "HH:MM"
    tz: string; // IANA
    idle_timeout_secs: number;
    chapter_workers: number;
    retention_days: number;
    start_url: string | null;
    rate_ms: number;
    cdn_host: string | null;
    cdn_rate_ms: number;
    cookie_domain: string | null;
    user_agent: string | null;
    download_allowlist: string[];
    allow_any_host: boolean;
    max_image_bytes: number;
    manga_limit: number;
    job_timeout_secs: number;
    metadata_max_consecutive_failures: number;
    browser_restart_threshold: number;
};

/** Read-only view of crawler fields managed via environment. */
export type CrawlerEnvOnly = {
    browser_mode: string;
    browser_args: string[];
    proxy: string | null;
    tor_control_url: string | null;
    tor_credentials_configured: boolean;
    session_configured: boolean;
};

export type CrawlerSettingsResponse = {
    editable: CrawlerSettings;
    env_only: CrawlerEnvOnly;
};

/** Admin-editable analysis settings. Prompts are `null` to mean "use the
 *  compiled default" (see {@link PromptDefaults}). The vision API key is
 *  env-only and never returned. */
export type AnalysisSettings = {
    enabled: boolean;
    workers: number;
    endpoint: string;
    model: string;
    request_timeout_secs: number;
    job_timeout_secs: number;
    max_tokens: number;
    max_pixels: number;
    min_slice_height: number;
    slice_overlap: number;
    tall_aspect_threshold: number;
    max_slices: number;
    max_image_bytes: number;
    response_format: 'json_schema' | 'json_object' | 'none';
    frequency_penalty: number;
    temperature: number;
    system_prompt: string | null;
    ocr_prompt: string | null;
    grounding_prompt: string | null;
};

export type PromptDefaults = {
    system_prompt: string;
    ocr_prompt: string;
    grounding_prompt: string;
};

export type AnalysisSettingsResponse = {
    editable: AnalysisSettings;
    env_only: { api_key_configured: boolean };
    prompt_defaults: PromptDefaults;
};

export async function getCrawlerSettings(): Promise<CrawlerSettingsResponse> {
    return request<CrawlerSettingsResponse>('/v1/admin/settings/crawler');
}

export async function updateCrawlerSettings(
    settings: CrawlerSettings
): Promise<CrawlerSettingsResponse> {
    return request<CrawlerSettingsResponse>('/v1/admin/settings/crawler', {
        method: 'PUT',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(settings)
    });
}

export async function getAnalysisSettings(): Promise<AnalysisSettingsResponse> {
    return request<AnalysisSettingsResponse>('/v1/admin/settings/analysis');
}

export async function updateAnalysisSettings(
    settings: AnalysisSettings
): Promise<AnalysisSettingsResponse> {
    return request<AnalysisSettingsResponse>('/v1/admin/settings/analysis', {
        method: 'PUT',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(settings)
    });
}

// ---- admin audit log -------------------------------------------------------

export type AuditEntry = {
    id: string;
    actor_user_id: string | null;
    /** Actor's current username; null when the account was deleted. */
    actor_username: string | null;
    action: string;
    target_kind: string;
    target_id: string | null;
    payload: unknown;
    at: string;
};

export type AuditPage = { items: AuditEntry[]; page: Page };

export type ListAuditOptions = {
    action?: string;
    targetKind?: string;
    actorUserId?: string;
    days?: number;
    limit?: number;
    offset?: number;
};

export async function listAuditLog(
    opts: ListAuditOptions = {},
    init?: RequestInit
): Promise<AuditPage> {
    const params = new URLSearchParams();
    if (opts.action) params.set('action', opts.action);
    if (opts.targetKind) params.set('target_kind', opts.targetKind);
    if (opts.actorUserId) params.set('actor_user_id', opts.actorUserId);
    if (opts.days != null && opts.days > 0) params.set('days', String(opts.days));
    if (opts.limit != null) params.set('limit', String(opts.limit));
    if (opts.offset != null) params.set('offset', String(opts.offset));
    const qs = params.toString();
    return request<AuditPage>(`/v1/admin/audit${qs ? `?${qs}` : ''}`, init);
}

// ---- operational health checks ---------------------------------------------

export type CheckStatus = 'ok' | 'warn' | 'critical';

export type HealthCheck = {
    id: string;
    label: string;
    status: CheckStatus;
    value: string;
    threshold: string | null;
    hint: string | null;
    /** Admin route that remediates this check, when one applies. */
    action_href: string | null;
};

export type HealthThresholds = {
    disk_pct: number;
    mem_pct: number;
    dead_jobs_max: number;
    crawl_fail_pct: number;
    analysis_fail_pct: number;
    cron_freshness_hours: number;
    missing_covers_max: number;
};

export type HealthReport = {
    status: CheckStatus;
    checks: HealthCheck[];
    thresholds: HealthThresholds;
};

export async function getHealth(init?: RequestInit): Promise<HealthReport> {
    return request<HealthReport>('/v1/admin/health', init);
}

export async function updateHealthThresholds(t: HealthThresholds): Promise<HealthReport> {
    return request<HealthReport>('/v1/admin/health/thresholds', {
        method: 'PUT',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(t)
    });
}

// ---- metrics trend series (crawler + analysis charts) ----------------------

export type MetricsBucket = {
    /** Bucket start (ISO timestamp). */
    t: string;
    n: number;
    ok: number;
    failed: number;
    avg_ms: number | null;
};

export type MetricsSeries = { buckets: MetricsBucket[] };

function seriesQs(days: number): string {
    const params = new URLSearchParams();
    if (days > 0) params.set('days', String(days));
    const qs = params.toString();
    return qs ? `?${qs}` : '';
}

export async function getCrawlerMetricsSeries(
    days = 7,
    init?: RequestInit
): Promise<MetricsSeries> {
    return request<MetricsSeries>(`/v1/admin/crawler/metrics/series${seriesQs(days)}`, init);
}

export async function getAnalysisMetricsSeries(
    days = 7,
    init?: RequestInit
): Promise<MetricsSeries> {
    return request<MetricsSeries>(`/v1/admin/analysis/metrics/series${seriesQs(days)}`, init);
}
