// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.
import {
    unitTest,
    assert,
    assertEquals,
    assertNotEquals,
    assertThrows,
} from "./test_util.ts";

function delay(seconds: number): Promise<void> {
    return new Promise<void>((resolve) => {
        setTimeout(() => {
            resolve();
        }, seconds);
    });
}

function readableStreamToArray<R>(
    readable: { getReader(): ReadableStreamDefaultReader<R> },
    reader?: ReadableStreamDefaultReader<R>
): Promise<R[]> {
    if (reader === undefined) {
        reader = readable.getReader();
    }

    const chunks: R[] = [];

    return pump();

    function pump(): Promise<R[]> {
        return reader!.read().then((result) => {
            if (result.done) {
                return chunks;
            }

            chunks.push(result.value);
            return pump();
        });
    }
}

unitTest(function transformStreamConstructedWithTransformFunction() {
    new TransformStream({ transform(): void {} });
});

unitTest(function transformStreamConstructedNoTransform() {
    new TransformStream();
    new TransformStream({});
});

unitTest(function transformStreamInstancesHaveProperProperties() {
    const ts = new TransformStream({ transform(): void {} });
    const proto = Object.getPrototypeOf(ts);

    const writableStream = Object.getOwnPropertyDescriptor(proto, "writable");
    assert(writableStream !== undefined, "it has a writable property");
    assert(!writableStream.enumerable, "writable should be non-enumerable");
    assertEquals(
        typeof writableStream.get,
        "function",
        "writable should have a getter"
    );
    assertEquals(
        writableStream.set,
        undefined,
        "writable should not have a setter"
    );
    assert(writableStream.configurable, "writable should be configurable");
    assert(
        ts.writable instanceof WritableStream,
        "writable is an instance of WritableStream"
    );
    assert(
        WritableStream.prototype.getWriter.call(ts.writable),
        "writable should pass WritableStream brand check"
    );

    const readableStream = Object.getOwnPropertyDescriptor(proto, "readable");
    assert(readableStream !== undefined, "it has a readable property");
    assert(!readableStream.enumerable, "readable should be non-enumerable");
    assertEquals(
        typeof readableStream.get,
        "function",
        "readable should have a getter"
    );
    assertEquals(
        readableStream.set,
        undefined,
        "readable should not have a setter"
    );
    assert(readableStream.configurable, "readable should be configurable");
    assert(
        ts.readable instanceof ReadableStream,
        "readable is an instance of ReadableStream"
    );
    assertNotEquals(
        ReadableStream.prototype.getReader.call(ts.readable),
        undefined,
        "readable should pass ReadableStream brand check"
    );
});

unitTest(function transformStreamWritableStartsAsWritable() {
    const ts = new TransformStream({ transform(): void {} });

    const writer = ts.writable.getWriter();
    assertEquals(writer.desiredSize, 1, "writer.desiredSize should be 1");
});

unitTest(async function transformStreamReadableCanReadOutOfWritable() {
    const ts = new TransformStream();

    const writer = ts.writable.getWriter();
    writer.write("a");
    assertEquals(
        writer.desiredSize,
        0,
        "writer.desiredSize should be 0 after write()"
    );

    const result = await ts.readable.getReader().read();
    assertEquals(
        result.value,
        "a",
        "result from reading the readable is the same as was written to writable"
    );
    assert(!result.done, "stream should not be done");

    await delay(0);
    assert(writer.desiredSize === 1, "desiredSize should be 1 again");
});

unitTest(async function transformStreamCanReadWhatIsWritten() {
    let c: TransformStreamDefaultController;
    const ts = new TransformStream({
        start(controller: TransformStreamDefaultController): void {
            c = controller;
        },
        transform(chunk: string): void {
            c.enqueue(chunk.toUpperCase());
        },
    });

    const writer = ts.writable.getWriter();
    writer.write("a");

    const result = await ts.readable.getReader().read();
    assertEquals(
        result.value,
        "A",
        "result from reading the readable is the transformation of what was written to writable"
    );
    assert(!result.done, "stream should not be done");
});

unitTest(async function transformStreamCanReadBothChunks() {
    let c: TransformStreamDefaultController;
    const ts = new TransformStream({
        start(controller: TransformStreamDefaultController): void {
            c = controller;
        },
        transform(chunk: string): void {
            c.enqueue(chunk.toUpperCase());
            c.enqueue(chunk.toUpperCase());
        },
    });

    const writer = ts.writable.getWriter();
    writer.write("a");

    const reader = ts.readable.getReader();

    const result1 = await reader.read();
    assertEquals(
        result1.value,
        "A",
        "the first chunk read is the transformation of the single chunk written"
    );
    assert(!result1.done, "stream should not be done");

    const result2 = await reader.read();
    assertEquals(
        result2.value,
        "A",
        "the second chunk read is also the transformation of the single chunk written"
    );
    assert(!result2.done, "stream should not be done");
});

unitTest(async function transformStreamCanReadWhatIsWritten() {
    let c: TransformStreamDefaultController;
    const ts = new TransformStream({
        start(controller: TransformStreamDefaultController): void {
            c = controller;
        },
        transform(chunk: string): Promise<void> {
            return delay(0).then(() => c.enqueue(chunk.toUpperCase()));
        },
    });

    const writer = ts.writable.getWriter();
    writer.write("a");

    const result = await ts.readable.getReader().read();
    assertEquals(
        result.value,
        "A",
        "result from reading the readable is the transformation of what was written to writable"
    );
    assert(!result.done, "stream should not be done");
});

unitTest(async function transformStreamAsyncReadMultipleChunks() {
    let doSecondEnqueue: () => void;
    let returnFromTransform: () => void;
    const ts = new TransformStream({
        transform(
            chunk: string,
            controller: TransformStreamDefaultController
        ): Promise<void> {
            delay(0).then(() => controller.enqueue(chunk.toUpperCase()));
            doSecondEnqueue = (): void =>
                controller.enqueue(chunk.toUpperCase());
            return new Promise((resolve) => {
                returnFromTransform = resolve;
            });
        },
    });

    const reader = ts.readable.getReader();

    const writer = ts.writable.getWriter();
    writer.write("a");

    const result1 = await reader.read();
    assertEquals(
        result1.value,
        "A",
        "the first chunk read is the transformation of the single chunk written"
    );
    assert(!result1.done, "stream should not be done");
    doSecondEnqueue!();

    const result2 = await reader.read();
    assertEquals(
        result2.value,
        "A",
        "the second chunk read is also the transformation of the single chunk written"
    );
    assert(!result2.done, "stream should not be done");
    returnFromTransform!();
});

unitTest(function transformStreamClosingWriteClosesRead() {
    const ts = new TransformStream({ transform(): void {} });

    const writer = ts.writable.getWriter();
    writer.close();

    return Promise.all([writer.closed, ts.readable.getReader().closed]).then(
        undefined
    );
});

unitTest(async function transformStreamCloseWaitAwaitsTransforms() {
    let transformResolve: () => void;
    const transformPromise = new Promise<void>((resolve) => {
        transformResolve = resolve;
    });
    const ts = new TransformStream(
        {
            transform(): Promise<void> {
                return transformPromise;
            },
        },
        undefined,
        { highWaterMark: 1 }
    );

    const writer = ts.writable.getWriter();
    writer.write("a");
    writer.close();

    let rsClosed = false;
    ts.readable.getReader().closed.then(() => {
        rsClosed = true;
    });

    await delay(0);
    assertEquals(rsClosed, false, "readable is not closed after a tick");
    transformResolve!();

    await writer.closed;
    // TODO: Is this expectation correct?
    assertEquals(rsClosed, true, "readable is closed at that point");
});

unitTest(async function transformStreamCloseWriteAfterSyncEnqueues() {
    let c: TransformStreamDefaultController<string>;
    const ts = new TransformStream<string, string>({
        start(controller: TransformStreamDefaultController): void {
            c = controller;
        },
        transform(): Promise<void> {
            c.enqueue("x");
            c.enqueue("y");
            return delay(0);
        },
    });

    const writer = ts.writable.getWriter();
    writer.write("a");
    writer.close();

    const readableChunks = readableStreamToArray(ts.readable);

    await writer.closed;
    const chunks = await readableChunks;
    assertEquals(
        chunks,
        ["x", "y"],
        "both enqueued chunks can be read from the readable"
    );
});

unitTest(async function transformStreamWritableCloseAsyncAfterAsyncEnqueues() {
    let c: TransformStreamDefaultController<string>;
    const ts = new TransformStream<string, string>({
        start(controller: TransformStreamDefaultController<string>): void {
            c = controller;
        },
        transform(): Promise<void> {
            return delay(0)
                .then(() => c.enqueue("x"))
                .then(() => c.enqueue("y"))
                .then(() => delay(0));
        },
    });

    const writer = ts.writable.getWriter();
    writer.write("a");
    writer.close();

    const readableChunks = readableStreamToArray(ts.readable);

    await writer.closed;
    const chunks = await readableChunks;
    assertEquals(
        chunks,
        ["x", "y"],
        "both enqueued chunks can be read from the readable"
    );
});

unitTest(async function transformStreamTransformerMethodsCalledAsMethods() {
    let c: TransformStreamDefaultController<string>;
    const transformer = {
        suffix: "-suffix",

        start(controller: TransformStreamDefaultController<string>): void {
            c = controller;
            c.enqueue("start" + this.suffix);
        },

        transform(chunk: string): void {
            c.enqueue(chunk + this.suffix);
        },

        flush(): void {
            c.enqueue("flushed" + this.suffix);
        },
    };
    const ts = new TransformStream(transformer);

    const writer = ts.writable.getWriter();
    writer.write("a");
    writer.close();

    const readableChunks = readableStreamToArray(ts.readable);

    await writer.closed;
    const chunks = await readableChunks;
    assertEquals(
        chunks,
        ["start-suffix", "a-suffix", "flushed-suffix"],
        "all enqueued chunks have suffixes"
    );
});

unitTest(async function transformStreamMethodsShouldNotBeAppliedOrCalled() {
    function functionWithOverloads(): void {}
    functionWithOverloads.apply = (): void => {
        throw new Error("apply() should not be called");
    };
    functionWithOverloads.call = (): void => {
        throw new Error("call() should not be called");
    };
    const ts = new TransformStream({
        start: functionWithOverloads,
        transform: functionWithOverloads,
        flush: functionWithOverloads,
    });
    const writer = ts.writable.getWriter();
    writer.write("a");
    writer.close();

    await readableStreamToArray(ts.readable);
});

unitTest(async function transformStreamCallTransformSync() {
    let transformCalled = false;
    const ts = new TransformStream(
        {
            transform(): void {
                transformCalled = true;
            },
        },
        undefined,
        { highWaterMark: Infinity }
    );
    // transform() is only called synchronously when there is no backpressure and
    // all microtasks have run.
    await delay(0);
    const writePromise = ts.writable.getWriter().write(undefined);
    assert(transformCalled, "transform() should have been called");
    await writePromise;
});

unitTest(function transformStreamCloseWriteClosesReadWithNoChunks() {
    const ts = new TransformStream({}, undefined, { highWaterMark: 0 });

    const writer = ts.writable.getWriter();
    writer.close();

    return Promise.all([writer.closed, ts.readable.getReader().closed]).then(
        undefined
    );
});

unitTest(function transformStreamEnqueueThrowsAfterTerminate() {
    new TransformStream({
        start(controller: TransformStreamDefaultController): void {
            controller.terminate();
            assertThrows(() => {
                controller.enqueue(undefined);
            }, TypeError);
        },
    });
});

unitTest(function transformStreamEnqueueThrowsAfterReadableCancel() {
    let controller: TransformStreamDefaultController;
    const ts = new TransformStream({
        start(c: TransformStreamDefaultController): void {
            controller = c;
        },
    });
    const cancelPromise = ts.readable.cancel();
    assertThrows(
        () => controller.enqueue(undefined),
        TypeError,
        undefined,
        "enqueue should throw"
    );
    return cancelPromise;
});

unitTest(function transformStreamSecondTerminateNoOp() {
    new TransformStream({
        start(controller: TransformStreamDefaultController): void {
            controller.terminate();
            controller.terminate();
        },
    });
});

unitTest(async function transformStreamTerminateAfterReadableCancelIsNoop() {
    let controller: TransformStreamDefaultController;
    const ts = new TransformStream({
        start(c: TransformStreamDefaultController): void {
            controller = c;
        },
    });
    const cancelReason = { name: "cancelReason" };
    const cancelPromise = ts.readable.cancel(cancelReason);
    controller!.terminate();
    await cancelPromise;
    try {
        await ts.writable.getWriter().closed;
    } catch (e) {
        assert(e === cancelReason);
        return;
    }
    throw new Error("closed should have rejected");
});

unitTest(async function transformStreamStartCalledOnce() {
    let calls = 0;
    new TransformStream({
        start(): void {
            ++calls;
        },
    });
    await delay(0);
    assertEquals(calls, 1, "start() should have been called exactly once");
});

unitTest(function transformStreamReadableTypeThrows() {
    assertThrows(
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        () => new TransformStream({ readableType: "bytes" as any }),
        RangeError,
        undefined,
        "constructor should throw"
    );
});

unitTest(function transformStreamWritableTypeThrows() {
    assertThrows(
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        () => new TransformStream({ writableType: "bytes" as any }),
        RangeError,
        undefined,
        "constructor should throw"
    );
});

unitTest(function transformStreamSubclassable() {
    class Subclass extends TransformStream {
        extraFunction(): boolean {
            return true;
        }
    }
    assert(
        Object.getPrototypeOf(Subclass.prototype) === TransformStream.prototype,
        "Subclass.prototype's prototype should be TransformStream.prototype"
    );
    assert(
        Object.getPrototypeOf(Subclass) === TransformStream,
        "Subclass's prototype should be TransformStream"
    );
    const sub = new Subclass();
    assert(
        sub instanceof TransformStream,
        "Subclass object should be an instance of TransformStream"
    );
    assert(
        sub instanceof Subclass,
        "Subclass object should be an instance of Subclass"
    );
    const readableGetter = Object.getOwnPropertyDescriptor(
        TransformStream.prototype,
        "readable"
    )!.get;
    assert(
        readableGetter!.call(sub) === sub.readable,
        "Subclass object should pass brand check"
    );
    assert(
        sub.extraFunction(),
        "extraFunction() should be present on Subclass object"
    );
});
