class StartupError extends Error {}

/*
 * We need to know the bundle path before we can fetch the sourcemap files.  In a production environment, we can guess
 * it using this.
 */
async function getBundleName(baseUrl) {
    const res = await fetch(new URL("index.html", baseUrl).toString());
    if (!res.ok) {
        throw new StartupError(`Couldn't fetch index.html to prefill bundle; ${res.status} ${res.statusText}`);
    }
    const index = await res.text();
    return index
        .split("\n")
        .map((line) => line.match(/<script src="bundles\/([^/]+)\/bundle.js"/))
        .filter((result) => result)
        .map((result) => result[1])[0];
}

function validateBundle(value) {
    return value.match(/^[0-9a-f]{20}$/) ? Some.of(value) : None;
}

/* A custom fetcher that abandons immediately upon getting a response.
 * The purpose of this is just to validate that the user entered a real bundle, and provide feedback.
 */
const bundleCache = new Map();
function bundleSubject(baseUrl, bundle) {
    if (!bundle.match(/^[0-9a-f]{20}$/)) throw new Error("Bad input");
    if (bundleCache.has(bundle)) {
        return bundleCache.get(bundle);
    }
    const fetcher = new rxjs.BehaviorSubject(Pending.of());
    bundleCache.set(bundle, fetcher);

    fetch(new URL(`bundles/${bundle}/bundle.js.map`, baseUrl).toString()).then((res) => {
        res.body.cancel(); /* Bail on the download immediately - it could be big! */
        const status = res.ok;
        if (status) {
            fetcher.next(Success.of());
        } else {
            fetcher.next(FetchError.of(`Failed to fetch: ${res.status} ${res.statusText}`));
        }
    });

    return fetcher;
}

/*
 * Convert a ReadableStream of bytes into an Observable of a string
 * The observable will emit a stream of Pending objects and will concatenate
 * the number of bytes received with whatever pendingContext has been supplied.
 * Finally, it will emit a Success containing the result.
 * You'd use this on a Response.body.
 */
function observeReadableStream(readableStream, pendingContext = {}) {
    let bytesReceived = 0;
    let buffer = "";
    const pendingSubject = new rxjs.BehaviorSubject(Pending.of({ ...pendingContext, bytesReceived }));
    const throttledPending = pendingSubject.pipe(rxjs.operators.throttleTime(100));
    const resultObservable = new rxjs.Subject();
    const reader = readableStream.getReader();
    const utf8Decoder = new TextDecoder("utf-8");
    function readNextChunk() {
        reader.read().then(({ done, value }) => {
            if (done) {
                pendingSubject.complete();
                resultObservable.next(Success.of(buffer));
                return;
            }
            bytesReceived += value.length;
            pendingSubject.next(Pending.of({ ...pendingContext, bytesReceived }));
            /* string concatenation is apparently the most performant way to do this */
            buffer += utf8Decoder.decode(value);
            readNextChunk();
        });
    }
    readNextChunk();
    return rxjs.concat(throttledPending, resultObservable);
}

/*
 * A wrapper which converts the browser's `fetch()` mechanism into an Observable.  The Observable then provides us with
 * a stream of datatype values: first, a sequence of Pending objects that keep us up to date with the download progress,
 * finally followed by either a Success or Failure object.  React then just has to render each of these appropriately.
 */
const fetchCache = new Map();
function fetchAsSubject(endpoint) {
    if (fetchCache.has(endpoint)) {
        // TODO: expiry/retry logic here?
        return fetchCache.get(endpoint);
    }
    const fetcher = new rxjs.BehaviorSubject(Pending.of());
    fetchCache.set(endpoint, fetcher);

    fetch(endpoint).then((res) => {
        if (!res.ok) {
            fetcher.next(FetchError.of(`Failed to fetch endpoint ${endpoint}: ${res.status} ${res.statusText}`));
            return;
        }

        const contentLength = res.headers.get("content-length");
        const context = contentLength ? { length: parseInt(contentLength) } : {};

        const streamer = observeReadableStream(res.body, context);
        streamer.subscribe((value) => {
            fetcher.next(value);
        });
    });
    return fetcher;
}

/* ===================== */
/* ==== React stuff ==== */
/* ===================== */
/* Rather than importing an entire build infrastructure, for now we just use React without JSX */
const e = React.createElement;

/*
 * Provides user feedback given a FetchStatus object.
 */
function ProgressBar({ fetchStatus }) {
    return e(
        "span",
        { className: "progress " },
        fetchStatus.fold({
            pending: ({ bytesReceived, length }) => {
                if (!bytesReceived) {
                    return e("span", { className: "spinner" }, "\u29b5");
                }
                const kB = Math.floor((10 * bytesReceived) / 1024) / 10;
                if (!length) {
                    return e("span", null, `Fetching (${kB}kB)`);
                }
                const percent = Math.floor((100 * bytesReceived) / length);
                return e("span", null, `Fetching (${kB}kB) ${percent}%`);
            },
            success: () => e("span", null, "\u2713"),
            error: (reason) => {
                return e("span", { className: "error" }, `\u2717 ${reason}`);
            },
        }),
    );
}

/*
 * The main component.
 */
function BundlePicker() {
    const [baseUrl, setBaseUrl] = React.useState(new URL("..", window.location).toString());
    const [bundle, setBundle] = React.useState("");
    const [file, setFile] = React.useState("");
    const [line, setLine] = React.useState("1");
    const [column, setColumn] = React.useState("");
    const [result, setResult] = React.useState(None);
    const [bundleFetchStatus, setBundleFetchStatus] = React.useState(None);
    const [fileFetchStatus, setFileFetchStatus] = React.useState(None);

    /* On baseUrl change, try to fill in the bundle name for the user */
    React.useEffect(() => {
        console.log("DEBUG", baseUrl);
        getBundleName(baseUrl).then((name) => {
            console.log("DEBUG", name);
            if (bundle === "" && validateBundle(name) !== None) {
                setBundle(name);
            }
        }, console.log.bind(console));
    }, [baseUrl]);

    /* ------------------------- */
    /* Follow user state changes */
    /* ------------------------- */
    const onBaseUrlChange = React.useCallback((event) => {
        const value = event.target.value;
        setBaseUrl(value);
    }, []);

    const onBundleChange = React.useCallback((event) => {
        const value = event.target.value;
        setBundle(value);
    }, []);

    const onFileChange = React.useCallback((event) => {
        const value = event.target.value;
        setFile(value);
    }, []);

    const onLineChange = React.useCallback((event) => {
        const value = event.target.value;
        setLine(value);
    }, []);

    const onColumnChange = React.useCallback((event) => {
        const value = event.target.value;
        setColumn(value);
    }, []);

    /* ------------------------------------------------ */
    /* Plumb data-fetching observables through to React */
    /* ------------------------------------------------ */

    /* Whenever a valid bundle name is input, go see if it's a real bundle on the server */
    React.useEffect(
        () =>
            validateBundle(bundle).fold({
                some: (value) => {
                    const subscription = bundleSubject(baseUrl, value)
                        .pipe(rxjs.operators.map(Some.of))
                        .subscribe(setBundleFetchStatus);
                    return () => subscription.unsubscribe();
                },
                none: () => setBundleFetchStatus(None),
            }),
        [baseUrl, bundle],
    );

    /* Whenever a valid javascript file is input, see if it corresponds to a sourcemap file and initiate a fetch
     * if so. */
    React.useEffect(() => {
        if (!file.match(/.\.js$/) || validateBundle(bundle) === None) {
            setFileFetchStatus(None);
            return;
        }
        const observable = fetchAsSubject(new URL(`bundles/${bundle}/${file}.map`, baseUrl).toString()).pipe(
            rxjs.operators.map((fetchStatus) =>
                fetchStatus.flatMap((value) => {
                    try {
                        return Success.of(JSON.parse(value));
                    } catch (e) {
                        return FetchError.of(e);
                    }
                }),
            ),
            rxjs.operators.map(Some.of),
        );
        const subscription = observable.subscribe(setFileFetchStatus);
        return () => subscription.unsubscribe();
    }, [baseUrl, bundle, file]);

    /*
     * Whenever we have a valid fetched sourcemap, and a valid line, attempt to find the original position from the
     * sourcemap.
     */
    React.useEffect(() => {
        // `fold` dispatches on the datatype, like a switch statement
        fileFetchStatus.fold({
            some: (fetchStatus) =>
                // `fold` just returns null for all of the cases that aren't `Success` objects here
                fetchStatus.fold({
                    success: (value) => {
                        if (!line) return setResult(None);
                        const pLine = parseInt(line);
                        const pCol = parseInt(column);
                        sourceMap.SourceMapConsumer.with(value, undefined, (consumer) =>
                            consumer.originalPositionFor({ line: pLine, column: pCol }),
                        ).then((result) => setResult(Some.of(JSON.stringify(result))));
                    },
                }),
            none: () => setResult(None),
        });
    }, [fileFetchStatus, line, column]);

    /* ------ */
    /* Render */
    /* ------ */
    return e(
        "div",
        {},
        e(
            "div",
            { className: "inputs" },
            e(
                "div",
                { className: "baseUrl" },
                e("label", { htmlFor: "baseUrl" }, "Base URL"),
                e("input", {
                    name: "baseUrl",
                    required: true,
                    pattern: ".+",
                    onChange: onBaseUrlChange,
                    value: baseUrl,
                }),
            ),
            e(
                "div",
                { className: "bundle" },
                e("label", { htmlFor: "bundle" }, "Bundle"),
                e("input", {
                    name: "bundle",
                    required: true,
                    pattern: "[0-9a-f]{20}",
                    onChange: onBundleChange,
                    value: bundle,
                }),
                bundleFetchStatus.fold({
                    some: (fetchStatus) => e(ProgressBar, { fetchStatus }),
                    none: () => null,
                }),
            ),
            e(
                "div",
                { className: "file" },
                e("label", { htmlFor: "file" }, "File"),
                e("input", {
                    name: "file",
                    required: true,
                    pattern: ".+\\.js",
                    onChange: onFileChange,
                    value: file,
                }),
                fileFetchStatus.fold({
                    some: (fetchStatus) => e(ProgressBar, { fetchStatus }),
                    none: () => null,
                }),
            ),
            e(
                "div",
                { className: "line" },
                e("label", { htmlFor: "line" }, "Line"),
                e("input", {
                    name: "line",
                    required: true,
                    pattern: "[0-9]+",
                    onChange: onLineChange,
                    value: line,
                }),
            ),
            e(
                "div",
                { className: "column" },
                e("label", { htmlFor: "column" }, "Column"),
                e("input", {
                    name: "column",
                    required: true,
                    pattern: "[0-9]+",
                    onChange: onColumnChange,
                    value: column,
                }),
            ),
        ),
        e(
            "div",
            null,
            result.fold({
                none: () => "Select a bundle, file and line",
                some: (value) => e("pre", null, value),
            }),
        ),
    );
}

/* Global stuff */
window.Decoder = {
    BundlePicker,
};