// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.

import {
  BooleanLiteral,
  EnumMember,
  Model,
  ModelProperty,
  NullType,
  NumericLiteral,
  Scalar,
  StringLiteral,
  Type,
  Union,
  UnknownType,
  VoidType,
  getDiscriminator,
  getMaxValue,
  getMinValue,
  isNeverType,
  isUnknownType,
} from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import { getJsScalar } from "../common/scalar.js";
import { JsContext, Module } from "../ctx.js";
import { reportDiagnostic } from "../lib.js";
import { isUnspeakable, parseCase } from "./case.js";
import { UnimplementedError, UnreachableError } from "./error.js";
import { getAllProperties } from "./extends.js";
import { categorize, indent } from "./iter.js";

/**
 * A tree structure representing a body of TypeScript code.
 */
export type CodeTree = Result | IfChain | Switch | Verbatim;

export type JsLiteralType = StringLiteral | BooleanLiteral | NumericLiteral | EnumMember;

/**
 * A TypeSpec type that is precise, i.e. the type of a single value.
 */
export type PreciseType = Scalar | Model | JsLiteralType | VoidType | NullType;

/**
 * Determines if `t` is a precise type.
 * @param t - the type to test
 * @returns true if `t` is precise, false otherwise.
 */
export function isPreciseType(t: Type): t is PreciseType {
  return (
    t.kind === "Scalar" ||
    t.kind === "Model" ||
    t.kind === "Boolean" ||
    t.kind === "Number" ||
    t.kind === "String" ||
    (t.kind === "Intrinsic" && (t.name === "void" || t.name === "null"))
  );
}

/**
 * An if-chain structure in the CodeTree DSL. This represents a cascading series of if-else-if statements with an optional
 * final `else` branch.
 */
export interface IfChain {
  kind: "if-chain";
  branches: IfBranch[];
  else?: CodeTree;
}

/**
 * A branch in an if-chain.
 */
export interface IfBranch {
  /**
   * A condition to test for this branch.
   */
  condition: Expression;
  /**
   * The body of this branch, to be executed if the condition is true.
   */
  body: CodeTree;
}

/**
 * A node in the code tree indicating that a precise type has been determined.
 */
export interface Result {
  kind: "result";
  type: PreciseType | UnknownType;
}

/**
 * A switch structure in the CodeTree DSL.
 */
export interface Switch {
  kind: "switch";
  /**
   * The expression to switch on.
   */
  condition: Expression;
  /**
   * The cases to test for.
   */
  cases: SwitchCase[];
  /**
   * The default case, if any.
   */
  default?: CodeTree;
}

/**
 * A verbatim code block.
 */
export interface Verbatim {
  kind: "verbatim";
  body: Iterable<string> | (() => Iterable<string>);
}

/**
 * A case in a switch statement.
 */
export interface SwitchCase {
  /**
   * The value to test for in this case.
   */
  value: Expression;
  /**
   * The body of this case.
   */
  body: CodeTree;
}

/**
 * An expression in the CodeTree DSL.
 */
export type Expression =
  | BinaryOp
  | UnaryOp
  | TypeOf
  | Literal
  | VerbatimExpression
  | SubjectReference
  | ModelPropertyReference
  | InRange;

/**
 * A binary operation.
 */
export interface BinaryOp {
  kind: "binary-op";
  /**
   * The operator to apply. This operation may be sensitive to the order of the left and right expressions.
   */
  operator:
    | "==="
    | "!=="
    | "<"
    | "<="
    | ">"
    | ">="
    | "+"
    | "-"
    | "*"
    | "/"
    | "%"
    | "&&"
    | "||"
    | "instanceof"
    | "in";
  /**
   * The left-hand-side operand.
   */
  left: Expression;
  /**
   * The right-hand-side operand.
   */
  right: Expression;
}

/**
 * A unary operation.
 */
export interface UnaryOp {
  kind: "unary-op";
  /**
   * The operator to apply.
   */
  operator: "!" | "-";
  /**
   * The operand to apply the operator to.
   */
  operand: Expression;
}

/**
 * A type-of operation.
 */
export interface TypeOf {
  kind: "typeof";
  /**
   * The operand to apply the `typeof` operator to.
   */
  operand: Expression;
}

/**
 * A literal JavaScript value. The value will be converted to the text of an expression that will yield the same value.
 */
export interface Literal {
  kind: "literal";
  /**
   * The value of the literal.
   */
  value: LiteralValue;
}

/**
 * A verbatim expression, written as-is with no modification.
 */
export interface VerbatimExpression {
  kind: "verbatim";
  /**
   * The exact text of the expression.
   */
  text: string;
}

/**
 * A reference to the "subject" of the code tree.
 *
 * The "subject" is a special expression denoting an input value.
 */
export interface SubjectReference {
  kind: "subject";
}

const SUBJECT = { kind: "subject" } as SubjectReference;

/**
 * A reference to a model property. Model property references are rendered by the `referenceModelProperty` function in the
 * options given to `writeCodeTree`, allowing the caller to define how model properties are stored.
 */
export interface ModelPropertyReference {
  kind: "model-property";
  property: ModelProperty;
}

/**
 * A check to see if a value is in an integer range.
 */
export interface InRange {
  kind: "in-range";
  /**
   * The expression to check.
   */
  expr: Expression;
  /**
   * The range to check against.
   */
  range: IntegerRange;
}

/**
 * A literal value that can be used in a JavaScript expression.
 */
export type LiteralValue = string | number | boolean | bigint;

function isLiteralValueType(type: Type): type is JsLiteralType {
  return (
    type.kind === "Boolean" ||
    type.kind === "Number" ||
    type.kind === "String" ||
    type.kind === "EnumMember"
  );
}

const PROPERTY_ID = (prop: ModelProperty) => parseCase(prop.name).camelCase;

/**
 * Differentiates the variants of a union type. This function returns a CodeTree that will test an input "subject" and
 * determine which of the cases it matches.
 *
 * Compared to `differentiateTypes`, this function is specialized for union types, and will consider union
 * discriminators first, then delegate to `differentiateTypes` for the remaining cases.
 *
 * @param ctx
 * @param type
 */
export function differentiateUnion(
  ctx: JsContext,
  module: Module,
  union: Union,
  renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID,
): CodeTree {
  const discriminator = getDiscriminator(ctx.program, union)?.propertyName;
  // Exclude `never` from the union variants.
  const variants = [...union.variants.values()].filter((v) => !isNeverType(v.type));

  if (variants.some((v) => isUnknownType(v.type))) {
    // Collapse the whole union to `unknown`.
    return { kind: "result", type: $(ctx.program).intrinsic.any };
  }

  if (!discriminator) {
    const cases = new Set<PreciseType>();

    for (const variant of variants) {
      if (!isPreciseType(variant.type)) {
        reportDiagnostic(ctx.program, {
          code: "undifferentiable-union-variant",
          target: variant,
        });
      } else {
        cases.add(variant.type);
      }
    }

    return differentiateTypes(ctx, module, cases, renderPropertyName);
  } else {
    const property = (variants[0].type as Model).properties.get(discriminator)!;

    return {
      kind: "switch",
      condition: {
        kind: "model-property",
        property,
      },
      cases: variants.map((v) => {
        const discriminatorPropertyType = (v.type as Model).properties.get(discriminator)!.type as
          | JsLiteralType
          | EnumMember;

        return {
          value: { kind: "literal", value: getJsValue(ctx, discriminatorPropertyType) },
          body: { kind: "result", type: v.type },
        } as SwitchCase;
      }),
      default: {
        kind: "verbatim",
        body: [
          'throw new Error("Unreachable: discriminator did not match any known value or was not present.");',
        ],
      },
    };
  }
}

/**
 * Differentiates a set of input types. This function returns a CodeTree that will test an input "subject" and determine
 * which of the cases it matches, executing the corresponding code block.
 *
 * @param ctx - The emitter context.
 * @param cases - A map of cases to differentiate to their respective code blocks.
 * @returns a CodeTree to use with `writeCodeTree`
 */
export function differentiateTypes(
  ctx: JsContext,
  module: Module,
  cases: Set<PreciseType>,
  renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID,
): CodeTree {
  if (cases.size === 0) {
    return {
      kind: "verbatim",
      body: [
        'throw new Error("Unreachable: encountered a value in differentiation where no variants exist.");',
      ],
    };
  }

  const categories = categorize(cases.keys(), (type) => type.kind);

  const literals = [
    ...(categories.Boolean ?? []),
    ...(categories.Number ?? []),
    ...(categories.String ?? []),
  ] as JsLiteralType[];
  const models = (categories.Model as Model[]) ?? [];
  const scalars = (categories.Scalar as Scalar[]) ?? [];

  const intrinsics = (categories.Intrinsic as (VoidType | NullType)[]) ?? [];

  if (literals.length + scalars.length + intrinsics.length === 0) {
    return differentiateModelTypes(ctx, module, select(models, cases), { renderPropertyName });
  } else {
    const branches: IfBranch[] = [];

    for (const intrinsic of intrinsics) {
      const intrinsicValue = intrinsic.name === "void" ? "undefined" : "null";
      branches.push({
        condition: {
          kind: "binary-op",
          operator: "===",
          left: SUBJECT,
          right: {
            kind: "verbatim",
            text: intrinsicValue,
          },
        },
        body: {
          kind: "result",
          type: intrinsic,
        },
      });
    }

    for (const literal of literals) {
      branches.push({
        condition: {
          kind: "binary-op",
          operator: "===",
          left: SUBJECT,
          right: { kind: "literal", value: getJsValue(ctx, literal) },
        },
        body: {
          kind: "result",
          type: literal,
        },
      });
    }

    const scalarRepresentations = new Map<string, Scalar>();

    for (const scalar of scalars) {
      const jsScalar = getJsScalar(ctx, module, scalar, scalar).type;

      if (scalarRepresentations.has(jsScalar)) {
        reportDiagnostic(ctx.program, {
          code: "undifferentiable-scalar",
          target: scalar,
          format: {
            competitor: scalarRepresentations.get(jsScalar)!.name,
          },
        });
        continue;
      }

      let test: Expression;

      switch (jsScalar) {
        case "Uint8Array":
          test = {
            kind: "binary-op",
            operator: "instanceof",
            left: SUBJECT,
            right: { kind: "verbatim", text: "Uint8Array" },
          };
          break;
        case "number":
          test = {
            kind: "binary-op",
            operator: "===",
            left: { kind: "typeof", operand: SUBJECT },
            right: { kind: "literal", value: "number" },
          };
          break;
        case "bigint":
          test = {
            kind: "binary-op",
            operator: "===",
            left: { kind: "typeof", operand: SUBJECT },
            right: { kind: "literal", value: "bigint" },
          };
          break;
        case "string":
          test = {
            kind: "binary-op",
            operator: "===",
            left: { kind: "typeof", operand: SUBJECT },
            right: { kind: "literal", value: "string" },
          };
          break;
        case "boolean":
          test = {
            kind: "binary-op",
            operator: "===",
            left: { kind: "typeof", operand: SUBJECT },
            right: { kind: "literal", value: "boolean" },
          };
          break;
        case "Date":
          test = {
            kind: "binary-op",
            operator: "instanceof",
            left: SUBJECT,
            right: { kind: "verbatim", text: "Date" },
          };
          break;
        default:
          throw new UnimplementedError(
            `scalar differentiation for unknown JS Scalar '${jsScalar}'.`,
          );
      }

      branches.push({
        condition: test,
        body: {
          kind: "result",
          type: scalar,
        },
      });
    }

    return {
      kind: "if-chain",
      branches,
      else:
        models.length > 0
          ? differentiateModelTypes(ctx, module, select(models, cases), { renderPropertyName })
          : undefined,
    };
  }

  /**
   * Select a subset of keys from a map.
   *
   * @param keys - The keys to select.
   * @param map - The map to select from.
   * @returns a map containing only those keys of the original map that were also in the `keys` iterable.
   */
  function select<V1, V2 extends V1>(keys: Iterable<V2>, set: Set<V1>): Set<V2> {
    const result = new Set<V2>();
    for (const key of keys) {
      if (set.has(key)) result.add(key);
    }
    return result;
  }
}

/**
 * Gets a JavaScript literal value for a given LiteralType.
 */
function getJsValue(ctx: JsContext, literal: JsLiteralType | EnumMember): LiteralValue {
  switch (literal.kind) {
    case "Boolean":
      return literal.value;
    case "Number": {
      const asNumber = literal.numericValue.asNumber();

      if (asNumber) return asNumber;

      const asBigInt = literal.numericValue.asBigInt();

      if (asBigInt) return asBigInt;

      reportDiagnostic(ctx.program, {
        code: "unrepresentable-numeric-constant",
        target: literal,
      });
      return 0;
    }
    case "String":
      return literal.value;
    case "EnumMember":
      return literal.value ?? literal.name;
    default:
      throw new UnreachableError(
        "getJsValue for " + (literal satisfies never as JsLiteralType).kind,
        { literal },
      );
  }
}

/**
 * An integer range, inclusive.
 */
type IntegerRange = [number, number];

function getIntegerRange(
  ctx: JsContext,
  module: Module,
  property: ModelProperty,
): IntegerRange | false {
  if (
    property.type.kind === "Scalar" &&
    getJsScalar(ctx, module, property.type, property).type === "number"
  ) {
    const minValue = getMinValue(ctx.program, property);
    const maxValue = getMaxValue(ctx.program, property);

    if (minValue !== undefined && maxValue !== undefined) {
      return [minValue, maxValue];
    }
  }

  return false;
}

function overlaps(range: IntegerRange, other: IntegerRange): boolean {
  return range[0] <= other[1] && range[1] >= other[0];
}

/**
 * Optional paramters for model differentiation.
 */
interface DifferentiateModelOptions {
  /**
   * A function that converts a model property reference over the subject to a string.
   *
   * Default: `(prop) => prop.name`
   */
  renderPropertyName?: (prop: ModelProperty) => string;

  /**
   * A filter function that determines which properties to consider for differentiation.
   *
   * Default: `() => true`
   */
  filter?: (prop: ModelProperty) => boolean;

  /**
   * The default case to use if no other cases match.
   *
   * Default: undefined.
   */
  else?: CodeTree | undefined;
}

const DEFAULT_DIFFERENTIATE_OPTIONS = {
  renderPropertyName: PROPERTY_ID,
  filter: () => true,
  else: undefined,
} as const;

/**
 * Differentiate a set of model types based on their properties. This function returns a CodeTree that will test an input
 * "subject" and determine which of the cases it matches, executing the corresponding code block.
 *
 * @param ctx - The emitter context.
 * @param models - A map of models to differentiate to their respective code blocks.
 * @param renderPropertyName - A function that converts a model property reference over the subject to a string.
 * @returns a CodeTree to use with `writeCodeTree`
 */
export function differentiateModelTypes(
  ctx: JsContext,
  module: Module,
  models: Set<Model>,
  options?: DifferentiateModelOptions,
): CodeTree;
export function differentiateModelTypes(
  ctx: JsContext,
  module: Module,
  models: Set<Model>,
  _options: DifferentiateModelOptions = {},
): CodeTree {
  const options = { ...DEFAULT_DIFFERENTIATE_OPTIONS, ..._options };
  // Horrible n^2 operation to get the unique properties of all models in the map, but hopefully n is small, so it should
  // be okay until you have a lot of models to differentiate.

  type PropertyName = string;
  type RenderedPropertyName = string & { __brand: "RenderedPropertyName" };

  const uniqueProps = new Map<Model, Set<PropertyName>>();

  // Map of property names to maps of literal values that identify a model.
  const propertyLiterals = new Map<RenderedPropertyName, Map<LiteralValue, Model>>();
  // Map of models to properties with values that can uniquely identify it
  const uniqueLiterals = new Map<Model, Set<RenderedPropertyName>>();

  const propertyRanges = new Map<RenderedPropertyName, Map<IntegerRange, Model>>();
  const uniqueRanges = new Map<Model, Set<RenderedPropertyName>>();

  for (const model of models) {
    const props = new Set<string>();

    for (const prop of getAllProperties(model).filter(options.filter)) {
      // Don't consider optional properties for differentiation.
      if (prop.optional) continue;

      // Ignore properties that have no parseable name.
      if (isUnspeakable(prop.name)) continue;

      const renderedPropName = options.renderPropertyName(prop) as RenderedPropertyName;

      // CASE - literal value

      if (isLiteralValueType(prop.type)) {
        let literals = propertyLiterals.get(renderedPropName);
        if (!literals) {
          literals = new Map();
          propertyLiterals.set(renderedPropName, literals);
        }

        const value = getJsValue(ctx, prop.type);

        const other = literals.get(value);

        if (other) {
          // Literal already used. Leave the literal in the propertyLiterals map to prevent future collisions,
          // but remove the model from the uniqueLiterals map.
          uniqueLiterals.get(other)?.delete(renderedPropName);
        } else {
          // Literal is available. Add the model to the uniqueLiterals map and set this value.
          literals.set(value, model);
          let modelsUniqueLiterals = uniqueLiterals.get(model);
          if (!modelsUniqueLiterals) {
            modelsUniqueLiterals = new Set();
            uniqueLiterals.set(model, modelsUniqueLiterals);
          }
          modelsUniqueLiterals.add(renderedPropName);
        }
      }

      // CASE - unique range

      const range = getIntegerRange(ctx, module, prop);
      if (range) {
        let ranges = propertyRanges.get(renderedPropName);
        if (!ranges) {
          ranges = new Map();
          propertyRanges.set(renderedPropName, ranges);
        }

        const overlappingRanges = [...ranges.entries()].filter(([r]) => overlaps(r, range));

        if (overlappingRanges.length > 0) {
          // Overlapping range found. Remove the model from the uniqueRanges map.
          for (const [, other] of overlappingRanges) {
            uniqueRanges.get(other)?.delete(renderedPropName);
          }
        } else {
          // No overlapping range found. Add the model to the uniqueRanges map and set this range.
          ranges.set(range, model);
          let modelsUniqueRanges = uniqueRanges.get(model);
          if (!modelsUniqueRanges) {
            modelsUniqueRanges = new Set();
            uniqueRanges.set(model, modelsUniqueRanges);
          }
          modelsUniqueRanges.add(renderedPropName);
        }
      }

      // CASE - unique property

      let valid = true;
      for (const [, other] of uniqueProps) {
        if (
          other.has(prop.name) ||
          (isLiteralValueType(prop.type) &&
            propertyLiterals
              .get(renderedPropName)
              ?.has(getJsValue(ctx, prop.type as JsLiteralType)))
        ) {
          valid = false;
          other.delete(prop.name);
        }
      }

      if (valid) {
        props.add(prop.name);
      }
    }

    uniqueProps.set(model, props);
  }

  const branches: IfBranch[] = [];

  let defaultCase: CodeTree | undefined = options.else;

  for (const [model, unique] of uniqueProps) {
    const literals = uniqueLiterals.get(model);
    const ranges = uniqueRanges.get(model);
    if (unique.size === 0 && (!literals || literals.size === 0) && (!ranges || ranges.size === 0)) {
      if (defaultCase) {
        reportDiagnostic(ctx.program, {
          code: "undifferentiable-model",
          target: model,
        });
        return defaultCase;
      } else {
        // Allow a single default case. This covers more APIs that have a single model that is not differentiated by a
        // unique property, in which case we can make it the `else` case.
        defaultCase = { kind: "result", type: model };
        continue;
      }
    }

    if (literals && literals.size > 0) {
      // A literal property value exists that can differentiate this model.
      const firstUniqueLiteral = literals.values().next().value as RenderedPropertyName;

      const property = [...model.properties.values()].find(
        (p) => (options.renderPropertyName(p) as RenderedPropertyName) === firstUniqueLiteral,
      )!;

      branches.push({
        condition: {
          kind: "binary-op",
          left: {
            kind: "binary-op",
            left: { kind: "literal", value: options.renderPropertyName(property) },
            operator: "in",
            right: SUBJECT,
          },
          operator: "&&",
          right: {
            kind: "binary-op",
            left: { kind: "model-property", property },
            operator: "===",
            right: {
              kind: "literal",
              value: getJsValue(ctx, property.type as JsLiteralType),
            },
          },
        },
        body: { kind: "result", type: model },
      });
    } else if (ranges && ranges.size > 0) {
      // A range property value exists that can differentiate this model.
      const firstUniqueRange = ranges.values().next().value as RenderedPropertyName;

      const property = [...model.properties.values()].find(
        (p) => options.renderPropertyName(p) === firstUniqueRange,
      )!;

      const range = [...propertyRanges.get(firstUniqueRange)!.entries()].find(
        ([range, candidate]) => candidate === model,
      )![0];

      branches.push({
        condition: {
          kind: "binary-op",
          left: {
            kind: "binary-op",
            left: { kind: "literal", value: options.renderPropertyName(property) },
            operator: "in",
            right: SUBJECT,
          },
          operator: "&&",
          right: {
            kind: "in-range",
            expr: { kind: "model-property", property },
            range,
          },
        },
        body: { kind: "result", type: model },
      });
    } else {
      const firstUniqueProp = unique.values().next().value as PropertyName;

      branches.push({
        condition: {
          kind: "binary-op",
          left: { kind: "literal", value: firstUniqueProp },
          operator: "in",
          right: SUBJECT,
        },
        body: { kind: "result", type: model },
      });
    }
  }

  return {
    kind: "if-chain",
    branches,
    else: defaultCase,
  };
}

/**
 * Options for the `writeCodeTree` function.
 */
export interface CodeTreeOptions {
  /**
   * The subject expression to use in the code tree.
   *
   * This text is used whenever a `SubjectReference` is encountered in the code tree, allowing the caller to specify
   * how the subject is stored and referenced.
   */
  subject: string;

  /**
   * A function that converts a model property to a string reference.
   *
   * This function is used whenever a `ModelPropertyReference` is encountered in the code tree, allowing the caller to
   * specify how model properties are stored and referenced.
   */
  referenceModelProperty: (p: ModelProperty) => string;

  /**
   * Renders a result when encountered in the code tree.
   */
  renderResult: (type: PreciseType | UnknownType) => Iterable<string>;
}

/**
 * Writes a code tree to text, given a set of options.
 *
 * @param ctx - The emitter context.
 * @param tree - The code tree to write.
 * @param options - The options to use when writing the code tree.
 */
export function* writeCodeTree(
  ctx: JsContext,
  tree: CodeTree,
  options: CodeTreeOptions,
): Iterable<string> {
  switch (tree.kind) {
    case "result":
      yield* options.renderResult(tree.type);
      break;
    case "if-chain": {
      let first = true;
      for (const branch of tree.branches) {
        const condition = writeExpression(ctx, branch.condition, options);
        if (first) {
          first = false;
          yield `if (${condition}) {`;
        } else {
          yield `} else if (${condition}) {`;
        }
        yield* indent(writeCodeTree(ctx, branch.body, options));
      }
      if (tree.else) {
        yield "} else {";
        yield* indent(writeCodeTree(ctx, tree.else, options));
      }
      yield "}";
      break;
    }
    case "switch": {
      yield `switch (${writeExpression(ctx, tree.condition, options)}) {`;
      for (const _case of tree.cases) {
        yield `  case ${writeExpression(ctx, _case.value, options)}: {`;
        yield* indent(indent(writeCodeTree(ctx, _case.body, options)));
        yield "  }";
      }
      if (tree.default) {
        yield "  default: {";
        yield* indent(indent(writeCodeTree(ctx, tree.default, options)));
        yield "  }";
      }
      yield "}";
      break;
    }
    case "verbatim":
      if (typeof tree.body === "function") {
        yield* tree.body();
      } else {
        yield* tree.body;
      }
      break;
    default:
      throw new UnreachableError("writeCodeTree for " + (tree satisfies never as CodeTree).kind, {
        tree,
      });
  }
}

function writeExpression(ctx: JsContext, expression: Expression, options: CodeTreeOptions): string {
  switch (expression.kind) {
    case "binary-op":
      return `(${writeExpression(ctx, expression.left, options)}) ${expression.operator} (${writeExpression(
        ctx,
        expression.right,
        options,
      )})`;
    case "unary-op":
      return `${expression.operator}(${writeExpression(ctx, expression.operand, options)})`;
    case "typeof":
      return `typeof (${writeExpression(ctx, expression.operand, options)})`;
    case "literal":
      switch (typeof expression.value) {
        case "string":
          return JSON.stringify(expression.value);
        case "number":
        case "bigint":
          return String(expression.value);
        case "boolean":
          return expression.value ? "true" : "false";
        default:
          throw new UnreachableError(
            `writeExpression for literal value type '${typeof expression.value}'`,
          );
      }
    case "in-range": {
      const {
        expr,
        range: [min, max],
      } = expression;
      const exprText = writeExpression(ctx, expr, options);

      return `(${exprText} >= ${min} && ${exprText} <= ${max})`;
    }
    case "verbatim":
      return expression.text;
    case "subject":
      return options.subject;
    case "model-property":
      return options.referenceModelProperty(expression.property);
    default:
      throw new UnreachableError(
        "writeExpression for " + (expression satisfies never as Expression).kind,
        {
          expression,
        },
      );
  }
}
