/*
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 *
 * This file is part of paged (https://paged.media) and is additionally
 * available under the Paged Media Enterprise License (PMEL). Full
 * copyright and license information is available in LICENSE.md which is
 * distributed with this source code.
 *
 *  @copyright  Copyright (c) And The Next GmbH
 *  @license    MPL-2.0 OR Paged Media Enterprise License (PMEL)
 */
import { clampScroll, clampZoom, currentPageAt, fitPage, scrollToPage, zoomAt, } from "./camera.js";
import { Emitter } from "./events.js";
import { attachInput } from "./input.js";
export class ViewerError extends Error {
    code;
    diagnostics;
    constructor(code, message, diagnostics) {
        super(message);
        this.name = "ViewerError";
        this.code = code;
        this.diagnostics = diagnostics;
    }
}
const ZOOM_STEP = Math.SQRT2;
function mapDiagnostics(diags) {
    const first = diags.messages.find((m) => m.severity === "error");
    const code = first?.code === "no_gpu" || first?.code === "gpu_init"
        ? "GPU_UNAVAILABLE"
        : first?.code === "open" || first?.code === "build"
            ? "PARSE_ERROR"
            : "UNSUPPORTED";
    return new ViewerError(code, first?.message ?? "load failed", diags);
}
async function sourceBytes(source) {
    if (typeof source === "string") {
        const res = await fetch(source);
        if (!res.ok) {
            throw new ViewerError("PARSE_ERROR", `fetch ${source}: ${res.status} ${res.statusText}`);
        }
        return new Uint8Array(await res.arrayBuffer());
    }
    if (source instanceof Uint8Array)
        return source;
    if (source instanceof ArrayBuffer)
        return new Uint8Array(source);
    return new Uint8Array(await source.arrayBuffer());
}
export async function createViewer(options) {
    const { canvas } = options;
    let session;
    try {
        session =
            typeof options.session === "function"
                ? await options.session()
                : options.session;
    }
    catch (e) {
        throw new ViewerError("GPU_UNAVAILABLE", e instanceof Error ? e.message : String(e));
    }
    const limits = {
        min: options.zoomLimits?.min ?? 0.1,
        max: options.zoomLimits?.max ?? 8,
    };
    const dpr = options.devicePixelRatio ??
        (typeof window !== "undefined" ? window.devicePixelRatio : 1);
    const schedule = options.schedule ??
        (typeof requestAnimationFrame === "function"
            ? (frame) => void requestAnimationFrame(frame)
            : (frame) => void queueMicrotask(frame));
    const events = new Emitter();
    let layout = { gapPt: 0, pages: [] };
    let camera = { zoom: 1, x: 0, y: 0 };
    let mode = options.layoutMode ?? "continuous";
    let page = 0;
    let bound = false;
    let disposed = false;
    let frameQueued = false;
    function viewport() {
        return {
            width: canvas.clientWidth || canvas.width || 1,
            height: canvas.clientHeight || canvas.height || 1,
        };
    }
    /** Layout the camera works against — single mode sees one page at y=0. */
    function activeLayout() {
        if (mode === "continuous")
            return layout;
        const p = layout.pages[page];
        return p
            ? { gapPt: layout.gapPt, pages: [{ ...p, yPt: 0, index: 0 }] }
            : { gapPt: layout.gapPt, pages: [] };
    }
    function requestFrame() {
        if (frameQueued || disposed || !bound)
            return;
        frameQueued = true;
        schedule(() => {
            frameQueued = false;
            if (disposed)
                return;
            const diags = session.present(camera.zoom, camera.x, camera.y, dpr, mode === "single" ? page : null);
            if (!diags.ok)
                events.emit("error", mapDiagnostics(diags));
        });
    }
    function applyCamera(next, force = false) {
        const clamped = clampScroll(next, activeLayout(), viewport());
        const zoomChanged = clamped.zoom !== camera.zoom;
        const scrollChanged = clamped.x !== camera.x || clamped.y !== camera.y;
        if (!zoomChanged && !scrollChanged && !force)
            return;
        camera = clamped;
        if (zoomChanged)
            events.emit("zoomChanged", { zoom: camera.zoom });
        if (scrollChanged)
            events.emit("scrollChanged", { x: camera.x, y: camera.y });
        if (mode === "continuous") {
            const now = currentPageAt(camera, layout, viewport());
            if (now !== page) {
                page = now;
                session.set_page(page);
                events.emit("pageChanged", { page });
            }
        }
        requestFrame();
    }
    function setPage(index) {
        const clamped = Math.max(0, Math.min(layout.pages.length - 1, index));
        if (clamped === page && layout.pages.length > 0)
            return;
        page = clamped;
        session.set_page(page);
        events.emit("pageChanged", { page });
        if (mode === "continuous") {
            applyCamera(scrollToPage(camera, layout, page, viewport()), true);
        }
        else {
            applyCamera(fitPage(activeLayout(), 0, "page", viewport(), limits), true);
        }
    }
    const detachInput = attachInput(canvas, options.input ?? {}, {
        zoomAt: (factor, anchor) => applyCamera(zoomAt(camera, camera.zoom * factor, limits, viewport(), anchor)),
        panBy: (dx, dy) => applyCamera({ zoom: camera.zoom, x: camera.x + dx, y: camera.y + dy }),
        zoomStep: (dir, anchor) => applyCamera(zoomAt(camera, camera.zoom * (dir > 0 ? ZOOM_STEP : 1 / ZOOM_STEP), limits, viewport(), anchor)),
        fit: () => viewer.fit("page"),
        pageStep: (dir) => setPage(page + dir),
        home: () => setPage(0),
        end: () => setPage(layout.pages.length - 1),
    });
    const viewer = {
        async load(source) {
            const bytes = await sourceBytes(source);
            const diags = session.load(bytes);
            if (!diags.ok) {
                const error = mapDiagnostics(diags);
                events.emit("error", error);
                throw error;
            }
            if (!bound) {
                const bind = await session.render_to_canvas_main(canvas);
                if (!bind.ok) {
                    const error = mapDiagnostics(bind);
                    events.emit("error", error);
                    throw error;
                }
                bound = true;
                session.resize(viewport().width, viewport().height, dpr);
            }
            layout = session.page_layout();
            page = 0;
            session.set_page(0);
            events.emit("loaded", { pageCount: layout.pages.length });
            applyCamera(fitPage(activeLayout(), 0, "page", viewport(), limits), true);
        },
        get zoom() {
            return camera.zoom;
        },
        setZoom(zoom, opts) {
            applyCamera(zoomAt(camera, clampZoom(zoom, limits), limits, viewport(), opts?.anchor));
        },
        zoomIn() {
            this.setZoom(camera.zoom * ZOOM_STEP);
        },
        zoomOut() {
            this.setZoom(camera.zoom / ZOOM_STEP);
        },
        fit(fitMode) {
            const idx = mode === "single" ? 0 : page;
            applyCamera(fitPage(activeLayout(), idx, fitMode, viewport(), limits), true);
        },
        get minZoom() {
            return limits.min;
        },
        get maxZoom() {
            return limits.max;
        },
        get scroll() {
            return { x: camera.x, y: camera.y };
        },
        scrollTo(x, y) {
            applyCamera({ zoom: camera.zoom, x, y });
        },
        scrollBy(dx, dy) {
            applyCamera({ zoom: camera.zoom, x: camera.x + dx, y: camera.y + dy });
        },
        get pageCount() {
            return layout.pages.length;
        },
        get currentPage() {
            return page;
        },
        goToPage(index) {
            setPage(index);
        },
        layoutMode(next) {
            if (next && next !== mode) {
                mode = next;
                applyCamera(fitPage(activeLayout(), mode === "single" ? 0 : page, "page", viewport(), limits), true);
            }
            return mode;
        },
        renderPageThumbnail(index, opts) {
            return session.render_page_to_bytes(index, opts?.width ?? 160);
        },
        on(event, listener) {
            return events.on(event, listener);
        },
        dispose() {
            if (disposed)
                return;
            disposed = true;
            detachInput();
            events.clear();
            session.free?.();
        },
    };
    return viewer;
}
