import { resolver } from "@rocicorp/resolver";
import { Subscribable } from "../../../shared/src/subscribable.js";
import { Hidden, ClientClosed, Offline, ConnectTimeout, AbruptClose, CleanClose } from "./client-error-kind-enum.js";
import { Connecting, NeedsAuth, Error, Closed, Disconnected, Connected } from "./connection-status-enum.js";
import { ClientError, isClientError } from "./error.js";
const DEFAULT_TIMEOUT_CHECK_INTERVAL_MS = 1e3;
const TERMINAL_STATES = [
  NeedsAuth,
  Error
];
class ConnectionManager extends Subscribable {
  #state;
  #connectRequestResolver = resolver();
  /**
   * The timestamp when we first started trying to connect.
   * This is used to track the retry window.
   * Reset to undefined when we successfully connect or when we transition to disconnected.
   */
  #connectingStartedAt;
  /**
   * The amount of milliseconds we allow for continuous connecting attempts before
   * transitioning to disconnected state.
   */
  #disconnectTimeout;
  /**
   * Handle for the timeout interval that periodically checks whether we've
   * exceeded the allowed connecting window.
   */
  #timeoutInterval;
  /**
   * Interval duration for checking whether the connecting timeout has elapsed.
   */
  #timeoutCheckIntervalMs;
  /**
   * Resolver used to signal waiting callers when the state changes.
   */
  #stateChangeResolver = resolver();
  constructor(options) {
    super();
    const now = Date.now();
    this.#disconnectTimeout = options.disconnectTimeout;
    this.#timeoutCheckIntervalMs = options.timeoutCheckIntervalMs ?? DEFAULT_TIMEOUT_CHECK_INTERVAL_MS;
    this.#state = {
      name: Connecting,
      attempt: 0,
      disconnectAt: now + this.#disconnectTimeout
    };
    this.#connectingStartedAt = now;
    this.#maybeStartTimeoutInterval();
  }
  get state() {
    return this.#state;
  }
  /**
   * Returns true if the current state is equal to the given status.
   */
  is(status) {
    return this.#state.name === status;
  }
  /**
   * Returns true if the current state is a terminal state
   * that can be recovered from by calling connect().
   */
  isInTerminalState() {
    return ConnectionManager.isTerminalState(this.#state);
  }
  /**
   * Returns true if the given status is a terminal state
   * that can be recovered from by calling connect().
   */
  static isTerminalState(state) {
    return TERMINAL_STATES.includes(
      state.name
    );
  }
  /**
   * Returns true if the run loop should continue.
   * The run loop continues in all states except closed.
   * In needs-auth and error states, the run loop pauses and waits for connect() to be called.
   */
  shouldContinueRunLoop() {
    return this.#state.name !== Closed;
  }
  /**
   * Waits for the next state change.
   * @returns A promise that resolves when the next state change occurs.
   */
  waitForStateChange() {
    return this.#nextStatePromise();
  }
  requestConnect() {
    if (this.#connectRequestResolver === void 0) {
      return;
    }
    this.#connectRequestResolver.resolve();
    this.#connectRequestResolver = void 0;
  }
  waitForConnectRequest() {
    return this.#connectRequestResolver === void 0 ? Promise.resolve() : this.#connectRequestResolver.promise;
  }
  #consumeConnectRequest() {
    if (this.#connectRequestResolver !== void 0) {
      return false;
    }
    this.#connectRequestResolver = resolver();
    return true;
  }
  /**
   * Consume a pending connect request and resume connecting.
   *
   * @returns true if a pending request was handled.
   */
  resumeFromConnectRequest() {
    if (!this.isInTerminalState()) {
      return false;
    }
    if (!this.#consumeConnectRequest()) {
      return false;
    }
    this.connecting();
    return true;
  }
  /**
   * Transition to connecting state.
   *
   * This starts the timeout timer, but if we've entered disconnected state,
   * we stay there and continue retrying.
   *
   * If we're disconnected due to a hidden tab, allow a transition back to
   * connecting when visibility returns.
   *
   * @returns An object containing a promise that resolves on the next state change.
   */
  connecting(reason) {
    if (this.#state.name === Closed) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    const isHiddenDisconnect = this.#state.name === Disconnected && this.#state.reason.kind === Hidden;
    if (this.#state.name === Disconnected && !isHiddenDisconnect) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    const now = Date.now();
    if (isHiddenDisconnect) {
      this.#connectingStartedAt = void 0;
    }
    if (this.#state.name === Connecting) {
      this.#state = {
        ...this.#state,
        attempt: this.#state.attempt + 1,
        reason
      };
      const nextStatePromise2 = this.#publishStateAndGetPromise();
      this.#maybeStartTimeoutInterval();
      return { nextStatePromise: nextStatePromise2 };
    }
    if (this.#connectingStartedAt === void 0) {
      this.#connectingStartedAt = now;
    }
    const disconnectAt = this.#connectingStartedAt + this.#disconnectTimeout;
    this.#state = {
      name: Connecting,
      attempt: 1,
      disconnectAt,
      reason
    };
    const nextStatePromise = this.#publishStateAndGetPromise();
    this.#maybeStartTimeoutInterval();
    return { nextStatePromise };
  }
  /**
   * Transition to connected state.
   * This resets the connecting timeout timer.
   *
   * @returns An object containing a promise that resolves on the next state change.
   */
  connected() {
    if (this.#state.name === Closed) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    if (this.#state.name === Connected) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    this.#connectingStartedAt = void 0;
    this.#maybeStopTimeoutInterval();
    this.#state = {
      name: Connected
    };
    const nextStatePromise = this.#publishStateAndGetPromise();
    return { nextStatePromise };
  }
  /**
   * Transition to disconnected state.
   * This is called when the timeout expires.
   * The run loop will continue trying to reconnect.
   *
   * @returns An object containing a promise that resolves on the next state change.
   */
  disconnected(reason) {
    if (this.#state.name === Closed) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    if (this.#state.name === Disconnected) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    if (this.#state.name === Connected) {
      this.#connectingStartedAt = void 0;
    }
    this.#maybeStopTimeoutInterval();
    this.#state = {
      name: Disconnected,
      reason
    };
    const nextStatePromise = this.#publishStateAndGetPromise();
    return { nextStatePromise };
  }
  /**
   * Transition to needs-auth state.
   * This pauses the run loop until connect() is called with new credentials.
   * Resets the retry window and attempt counter.
   *
   * @returns An object containing a promise that resolves on the next state change.
   */
  needsAuth(reason) {
    if (this.#state.name === Closed) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    if (this.#state.name === NeedsAuth) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    this.#connectingStartedAt = void 0;
    this.#maybeStopTimeoutInterval();
    this.#state = {
      name: NeedsAuth,
      reason
    };
    const nextStatePromise = this.#publishStateAndGetPromise();
    return { nextStatePromise };
  }
  /**
   * Transition to error state.
   * This pauses the run loop until connect() is called.
   * Resets the retry window and attempt counter.
   *
   * @returns An object containing a promise that resolves on the next state change.
   */
  error(reason) {
    if (this.#state.name === Closed) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    if (this.#state.name === Error) {
      return { nextStatePromise: this.#nextStatePromise() };
    }
    this.#connectingStartedAt = void 0;
    this.#maybeStopTimeoutInterval();
    this.#state = {
      name: Error,
      reason
    };
    const nextStatePromise = this.#publishStateAndGetPromise();
    return { nextStatePromise };
  }
  /**
   * Transition to closed state.
   * This is terminal - no further transitions are allowed.
   */
  closed() {
    if (this.#state.name === Closed) {
      return;
    }
    this.#connectingStartedAt = void 0;
    this.#maybeStopTimeoutInterval();
    this.#state = {
      name: Closed,
      reason: new ClientError({
        kind: ClientClosed,
        message: "Zero was explicitly closed by calling zero.close()"
      })
    };
    this.#publishState();
    this.cleanup();
    return;
  }
  cleanup = () => {
    this._listeners.clear();
    this.#resolveNextStateWaiters();
  };
  #resolveNextStateWaiters() {
    this.#stateChangeResolver.resolve(this.#state);
    this.#stateChangeResolver = resolver();
  }
  #publishState() {
    this.notify(this.#state);
    this.#resolveNextStateWaiters();
  }
  #nextStatePromise() {
    return this.#stateChangeResolver.promise;
  }
  #publishStateAndGetPromise() {
    this.#publishState();
    return this.#nextStatePromise();
  }
  /**
   * Check if we should transition from connecting to disconnected due to timeout.
   * Returns true if the transition happened.
   */
  #checkTimeout() {
    if (this.#state.name !== Connecting) {
      return false;
    }
    const now = Date.now();
    if (now >= this.#state.disconnectAt) {
      this.disconnected(
        new ClientError({
          kind: Offline,
          message: `Zero was unable to connect for ${Math.floor(this.#disconnectTimeout / 1e3)} seconds and was disconnected`
        })
      );
      return true;
    }
    return false;
  }
  #maybeStartTimeoutInterval() {
    if (this.#timeoutInterval !== void 0) {
      return;
    }
    this.#timeoutInterval = setInterval(() => {
      this.#checkTimeout();
    }, this.#timeoutCheckIntervalMs);
  }
  #maybeStopTimeoutInterval() {
    if (this.#timeoutInterval === void 0) {
      return;
    }
    clearInterval(this.#timeoutInterval);
    this.#timeoutInterval = void 0;
  }
}
const throwIfConnectionError = (state) => {
  if (ConnectionManager.isTerminalState(state) || state.name === Closed || (state.name === Connecting || state.name === Disconnected) && state.reason) {
    if (isClientError(state.reason) && (state.reason.kind === ConnectTimeout || state.reason.kind === AbruptClose || state.reason.kind === CleanClose || state.reason.kind === Hidden)) {
      return;
    }
    throw state.reason;
  }
};
export {
  ConnectionManager,
  throwIfConnectionError
};
//# sourceMappingURL=connection-manager.js.map
