// All backend calls go through this module. Components and routes import
// the typed helpers below — they do not call fetch directly.

const BASE = import.meta.env?.VITE_API_BASE ?? '/api';

/**
 * Builds an absolute URL to the streaming `/files/{key}` endpoint so
 * components can use it directly in `<img src>` etc., without
 * reconstructing the API base in each call site.
 *
 * Storage keys are `/`-separated paths, so each segment is percent-encoded
 * individually — the slashes stay literal path separators while any
 * reserved character inside a segment (`?`, `#`, `%`, space, …) can't be
 * reinterpreted as a query/fragment delimiter. Keys are backend-generated
 * today, so this is defence-in-depth against a future key source.
 */
export function fileUrl(key: string): string {
    const encoded = key.split('/').map(encodeURIComponent).join('/');
    return `${BASE}/v1/files/${encoded}`;
}

/**
 * Builds an API URL for non-`fetch` consumers (e.g. `EventSource` for SSE),
 * applying the same `VITE_API_BASE` prefix as `request()`. `path` is the
 * route after the base, e.g. `/v1/admin/crawler/stream`.
 */
export function apiUrl(path: string): string {
    return `${BASE}${path}`;
}

export class ApiError extends Error {
    constructor(
        public readonly status: number,
        public readonly code: string,
        message: string,
        /** The error envelope's `details` payload, when present (e.g. the
         *  per-field `{ fields: [...] }` of a `validation_failed` response). */
        public readonly details?: unknown
    ) {
        super(message);
        this.name = 'ApiError';
    }
}

type ErrorEnvelope = {
    error?: { code?: unknown; message?: unknown; details?: unknown };
};

/**
 * Optional hook fired the first moment `request()` observes a 401 on
 * any endpoint. Used by the session store to clear the cached user
 * when the server reports the session is no longer valid (expired
 * cookie, rotated server-side, password changed on another device).
 *
 * Set to `null` (or `undefined`) to disable. Tests that don't want
 * the side effect should leave it unset.
 */
let on401Hook: (() => void) | null = null;

export function setOn401Hook(handler: (() => void) | null): void {
    on401Hook = handler;
}

/** Per-call knobs that don't belong on the native `RequestInit`. */
export type RequestOptions = {
    /**
     * Skip the module-level 401 hook for this call. Used by endpoints
     * where a 401 does *not* mean "session expired" — e.g. the
     * change-password endpoint returns 401 for a wrong *current*
     * password while the caller is still fully authenticated. Firing
     * the hook there would clear the cached user and bounce them to
     * /login instead of surfacing the error inline.
     */
    suppressOn401?: boolean;
};

export async function request<T>(
    path: string,
    init?: RequestInit,
    opts?: RequestOptions
): Promise<T> {
    // Forward credentials (session cookie) explicitly so cross-origin
    // deployments — those configured via CORS_ALLOWED_ORIGINS — keep
    // working. For same-origin requests this is a no-op compared to the
    // default 'same-origin', so the same-origin happy path is
    // unchanged.
    const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init });
    if (!res.ok) {
        let code = 'http_error';
        let message = `${res.status} ${res.statusText}`;
        let details: unknown;
        const ct = res.headers.get('content-type') ?? '';
        try {
            if (ct.includes('application/json')) {
                const body = (await res.json()) as ErrorEnvelope;
                if (body?.error) {
                    if (typeof body.error.code === 'string' && body.error.code) {
                        code = body.error.code;
                    }
                    if (typeof body.error.message === 'string' && body.error.message) {
                        message = body.error.message;
                    }
                    if (body.error.details !== undefined) {
                        details = body.error.details;
                    }
                }
            } else {
                const text = await res.text();
                if (text) message = text;
            }
        } catch {
            // Body wasn't parseable; keep the http_error fallback.
        }
        if (res.status === 401 && on401Hook && !opts?.suppressOn401) {
            // Fire before throwing so the session store updates even
            // if the caller swallows the ApiError (e.g. the *OrEmpty
            // wrappers used by guest-rendering pages).
            try {
                on401Hook();
            } catch (e) {
                console.error('on401 hook threw:', e);
            }
        }
        throw new ApiError(res.status, code, message, details);
    }
    // Any empty body (not just 204) returns undefined — the manga-add
    // endpoint, for instance, signals create-vs-already-present via
    // 201/200 with no body, and callers typed `request<void>` would
    // otherwise blow up on `res.json()` parsing an empty string.
    if (res.status === 204) {
        return undefined as T;
    }
    const text = await res.text();
    if (!text) {
        return undefined as T;
    }
    return JSON.parse(text) as T;
}

export type Manga = {
    id: string;
    title: string;
    status: MangaStatus;
    alt_titles: string[];
    description: string | null;
    cover_image_path: string | null;
    created_at: string;
    updated_at: string;
};

export type MangaStatus = 'ongoing' | 'completed';

export type Page = {
    limit: number;
    offset: number;
    total: number | null;
};
