import { resolver } from "@rocicorp/resolver";
import { pipeline, Writable, Readable, Transform } from "node:stream";
import { createWebSocketStream } from "ws";
import { assert } from "../../../shared/src/asserts.js";
import { BigIntJSON } from "../../../shared/src/bigint-json.js";
import { Queue } from "../../../shared/src/queue.js";
import { parse } from "../../../shared/src/valita.js";
import { Subscription } from "./subscription.js";
import { sendPingsForLiveness, closeWithError, expectPingsForLiveness } from "./ws.js";
import { object, number } from "@badrap/valita";
const PING_INTERVAL_MS = 3e4;
function stream(lc, ws, inSchema, outOptions = {}, inOptions = {}, streamOptions = {}) {
  const endpoint = ws.url ?? "client";
  function close(err) {
    if (ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
      if (err) {
        closeWithError(lc, ws, err);
      } else {
        lc.info?.(`closing connection to ${endpoint}`);
        ws.close();
      }
    }
  }
  const instream = Subscription.create({
    ...inOptions,
    cleanup: (unconsumed, err) => {
      inOptions.cleanup?.(unconsumed, err);
      close(err);
    }
  });
  const outstream = Subscription.create({
    ...outOptions,
    cleanup: (unconsumed, err) => {
      outOptions.cleanup?.(unconsumed, err);
      close(err);
    }
  });
  const duplex = createWebSocketStream(ws, {
    ...streamOptions,
    decodeStrings: false
  });
  function streamOut2() {
    duplex.on("drain", () => lc.debug?.(`drained messages to ${endpoint}`));
    pipeline(
      Readable.from(outstream),
      new Transform({
        objectMode: true,
        transform: (msg, _encoding, callback) => callback(null, BigIntJSON.stringify(msg))
      }),
      duplex,
      (err) => err ? outstream.fail(err) : outstream.cancel()
    );
  }
  if (ws.readyState === ws.CONNECTING) {
    ws.on("open", () => {
      lc.info?.(`connected to ${endpoint}`);
      streamOut2();
    });
  } else {
    streamOut2();
  }
  pipe({
    source: duplex,
    sink: instream,
    parse: (chunk) => {
      const json = BigIntJSON.parse(chunk.toString());
      return parse(json, inSchema, "passthrough");
    }
  });
  sendPingsForLiveness(lc, ws, PING_INTERVAL_MS);
  return { outstream, instream };
}
function pipe({ source, sink, parse: parse2, bufferMessages }) {
  bufferMessages ??= 0;
  assert(bufferMessages >= 0, "bufferMessages must be non-negative");
  const pending = [];
  pipeline(
    source,
    new Writable({
      decodeStrings: false,
      write: (chunk, _encoding, callback) => {
        let msg;
        try {
          if ((msg = parse2(chunk)) === null) {
            callback();
            return;
          }
        } catch (err) {
          callback(ensureError(err));
          return;
        }
        const { result } = sink.push(msg);
        pending.push(result);
        void result.then(() => pending.shift());
        if (pending.length <= bufferMessages) {
          callback();
        } else {
          pending[0].then(
            () => callback(),
            (err) => callback(ensureError(err))
          );
        }
      },
      destroy: (err, callback) => {
        if (err) {
          sink.fail(ensureError(err));
        }
        callback();
      },
      final: (callback) => {
        sink.cancel();
        callback();
      }
    }),
    (err) => err ? sink.fail(err) : sink.cancel()
  );
}
function ensureError(err) {
  return err instanceof Error ? err : new Error(String(err));
}
const ackSchema = object({ ack: number() });
async function streamOut(lc, source, sink) {
  sendPingsForLiveness(lc, sink, PING_INTERVAL_MS);
  const closer = WebSocketCloser.forSource(lc, sink, source);
  const acks = new Queue();
  sink.addEventListener("message", ({ data }) => {
    try {
      if (typeof data !== "string") {
        throw new Error("Expected string message");
      }
      acks.enqueue(parse(JSON.parse(data), ackSchema));
    } catch (e) {
      lc.error?.(`error parsing ack`, e);
      closer.close(e);
    }
  });
  try {
    let nextID = 0;
    const { pipeline: pipeline2 } = source;
    if (pipeline2) {
      lc.debug?.(`started pipelined outbound stream`);
      for await (const { value: msg, consumed } of pipeline2) {
        const id = ++nextID;
        const data = BigIntJSON.stringify({ msg, id });
        sink.send(data);
        void (async () => {
          const { ack } = await acks.dequeue();
          if (ack !== id) {
            throw new Error(`Unexpected ack for ${id}: ${ack}`);
          }
          consumed();
        })();
      }
    } else {
      lc.debug?.(`started synchronous outbound stream`);
      for await (const msg of source) {
        const id = ++nextID;
        const data = BigIntJSON.stringify({ msg, id });
        sink.send(data);
        const { ack } = await acks.dequeue();
        if (ack !== id) {
          throw new Error(`Unexpected ack for ${id}: ${ack}`);
        }
      }
    }
    closer.close();
  } catch (e) {
    closer.close(e);
  }
}
async function streamIn(lc, source, schema) {
  expectPingsForLiveness(lc, source, PING_INTERVAL_MS);
  const streamedSchema = object({
    msg: schema,
    id: number()
  });
  const sink = new Subscription(
    {
      consumed: ({ id }) => source.send(JSON.stringify({ ack: id })),
      cleanup: () => closer.close()
    },
    ({ msg }) => msg
  );
  const closer = WebSocketCloser.forSink(lc, source, sink, handleMessage);
  function handleMessage(event) {
    const data = event.data.toString();
    if (!sink.active) {
      lc.warn?.("dropping ws message received after close", data);
      return;
    }
    try {
      const value = BigIntJSON.parse(data);
      const msg = parse(value, streamedSchema, "passthrough");
      sink.push(msg);
    } catch (e) {
      closer.close(e);
    }
  }
  await closer.connected;
  return sink;
}
class WebSocketCloser {
  #lc;
  #ws;
  #closeStream;
  #messageHandler;
  #connected = resolver();
  get connected() {
    return this.#connected.promise;
  }
  static forSource(lc, ws, stream2) {
    return new WebSocketCloser(lc, ws, () => stream2.cancel());
  }
  static forSink(lc, ws, stream2, messageHandler) {
    return new WebSocketCloser(lc, ws, () => stream2.end(), messageHandler);
  }
  constructor(lc, ws, closeStream, messageHandler) {
    this.#lc = lc;
    this.#ws = ws;
    this.#closeStream = closeStream;
    this.#messageHandler = messageHandler ?? null;
    ws.addEventListener("open", this.#handleOpen);
    ws.addEventListener("close", this.#handleClose);
    ws.addEventListener("error", this.#handleError);
    if (this.#messageHandler) {
      ws.addEventListener("message", this.#messageHandler);
    }
    switch (ws.readyState) {
      case ws.CONNECTING:
        break;
      // expected for new connections. resolve or reject in handlers.
      case ws.OPEN:
        this.#connected.resolve();
        break;
      default:
        this.#connected.reject(
          new Error(`websocket already in state ${ws.readyState}`)
        );
        break;
    }
  }
  get #conn() {
    return "connection" + (this.#ws.url ? ` to ${this.#ws.url}` : "");
  }
  #handleOpen = () => {
    this.#lc.info?.(`${this.#conn} established`);
    this.#connected.resolve();
  };
  #handleClose = (e) => {
    const { code, reason, wasClean } = e;
    this.#lc.info?.(`${this.#conn} closed`, {
      code,
      reason,
      wasClean
    });
    this.close();
    this.#connected.reject(`${this.#conn} closed with code ${code}`);
  };
  #handleError = ({ message, error }) => {
    if (this.#ws.readyState === this.#ws.OPEN) {
      this.#lc.error?.(`error in ${this.#conn}`, message, error);
    }
    this.#connected.reject(error);
  };
  close(err) {
    if (err) {
      this.#lc.error?.(`closing stream with error`, err);
    }
    this.#closeStream();
    if (!this.closed()) {
      this.#ws.close();
    }
  }
  closed() {
    return this.#ws.readyState === this.#ws.CLOSED || this.#ws.readyState === this.#ws.CLOSING;
  }
}
export {
  pipe,
  stream,
  streamIn,
  streamOut
};
//# sourceMappingURL=streams.js.map
