/**
 * Compose contract version compatibility.
 *
 * The version triple carries the compatibility class, and the rule is
 * permanent: 1.0 does not change it. A patch bump is additive: new ops, edge
 * families, materialisers, guards, preconditions, or new optional config
 * fields. A minor or major bump is breaking: anything removed, any existing
 * schema changed, or a semantics change under an unchanged wire shape. Both
 * sides of the handshake (see `headers.ts`) decide with `checkCompatibility`,
 * so the rule lives in exactly one place.
 */

export interface ComposeVersion {
  readonly major: number;
  readonly minor: number;
  readonly patch: number;
}

export type VersionCompatibility =
  'compatible' | 'sdk_outdated' | 'server_outdated';

const SEMANTIC_VERSION_PATTERN =
  /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;

/**
 * Parses the numeric triple of a semantic version. Prerelease and build
 * suffixes are accepted and ignored: `0.5.1-staging.3` compares as `0.5.1`.
 * Returns `undefined` for anything that is not a complete semver string,
 * including the `dev` sentinel the SDK sends when it runs unbundled.
 */
export const parseVersion = (version: string): ComposeVersion | undefined => {
  const match = SEMANTIC_VERSION_PATTERN.exec(version);
  if (!match) return undefined;

  const major = Number(match[1]);
  const minor = Number(match[2]);
  const patch = Number(match[3]);
  if (![major, minor, patch].every(Number.isSafeInteger)) return undefined;

  return { major, minor, patch };
};

const compareVersions = (a: ComposeVersion, b: ComposeVersion): number =>
  a.major !== b.major
    ? a.major - b.major
    : a.minor !== b.minor
      ? a.minor - b.minor
      : a.patch - b.patch;

/**
 * Decides whether a client built against `client` may talk to a server that
 * serves `server` and accepts clients down to `minimum`.
 *
 * - A client ahead of the server in any component is `server_outdated`. Even
 *   a patch-ahead client may send a field the server has never seen, and the
 *   guarantee must not depend on every server schema staying strict. This is
 *   the rollout window between an npm publish and the prod image bump.
 * - A client below `minimum` is `sdk_outdated`: the server no longer serves
 *   the contract that client speaks.
 * - Anything in between is compatible. Patch releases only add, so a client
 *   at or behind the server's patch never emits anything the server lacks;
 *   a client on an older minor is served because the server still emits the
 *   shapes that minor reads.
 *
 * `minimum` defaults to the server's own `major.minor.0`, which makes every
 * minor bump a hard cutover. The backend passes its explicit floor so a minor
 * bump can ship with the previous line still accepted; the SDK passes no floor
 * because the server, not the client, decides how far back it serves.
 */
export const checkCompatibility = (
  client: ComposeVersion,
  server: ComposeVersion,
  minimum: ComposeVersion = {
    major: server.major,
    minor: server.minor,
    patch: 0,
  },
): VersionCompatibility => {
  if (compareVersions(client, server) > 0) return 'server_outdated';
  if (compareVersions(client, minimum) < 0) return 'sdk_outdated';
  return 'compatible';
};
