import {
  type AnyNode,
  type MemberNode,
  type ObjectNode,
  type StringNode,
  type ValueNode,
  evaluate,
  print,
} from '@humanwhocodes/momoa';
import { type Token, type TokenNormalized, isAlias, isTokenMatch, splitID } from '@terrazzo/token-tools';
import type Logger from '../logger.js';
import type { ConfigInit } from '../types.js';
import { getObjMembers, injectObjMembers } from './json.js';

const listFormat = new Intl.ListFormat('en-us', { type: 'disjunction' });

export interface ValidateOptions {
  filename?: URL;
  src: string;
  logger: Logger;
}

export const VALID_COLORSPACES = new Set([
  'adobe-rgb',
  'display-p3',
  'hsl',
  'hwb',
  'lab',
  'lch',
  'oklab',
  'oklch',
  'prophoto',
  'rec2020',
  'srgb',
  'srgb-linear',
  'xyz',
  'xyz-d50',
  'xyz-d65',
]);

export const FONT_WEIGHT_VALUES = new Set([
  'thin',
  'hairline',
  'extra-light',
  'ultra-light',
  'light',
  'normal',
  'regular',
  'book',
  'medium',
  'semi-bold',
  'demi-bold',
  'bold',
  'extra-bold',
  'ultra-bold',
  'black',
  'heavy',
  'extra-black',
  'ultra-black',
]);

export const STROKE_STYLE_VALUES = new Set([
  'solid',
  'dashed',
  'dotted',
  'double',
  'groove',
  'ridge',
  'outset',
  'inset',
]);
export const STROKE_STYLE_LINE_CAP_VALUES = new Set(['round', 'butt', 'square']);

/** Distinct from isAlias() in that this accepts malformed aliases */
function isMaybeAlias(node: AnyNode) {
  if (node?.type === 'String') {
    return node.value.startsWith('{');
  }
  return false;
}

/** Assert object members match given types */
function validateMembersAs(
  $value: ObjectNode,
  properties: Record<string, { validator: typeof validateAliasSyntax; required?: boolean }>,
  node: AnyNode,
  { filename, src, logger }: ValidateOptions,
) {
  const members = getObjMembers($value);
  for (const [name, value] of Object.entries(properties)) {
    const { validator, required } = value;
    if (!members[name]) {
      if (required) {
        logger.error({
          group: 'parser',
          label: 'validate',
          message: `Missing required property "${name}"`,
          filename,
          node: $value,
          src,
        });
      }
      continue;
    }
    const memberValue = members[name];
    if (isMaybeAlias(memberValue)) {
      validateAliasSyntax(memberValue, node, { filename, src, logger });
    } else {
      validator(memberValue, node, { filename, src, logger });
    }
  }
}

/** Verify an Alias $value is formatted correctly */
export function validateAliasSyntax($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
  if ($value.type !== 'String' || !isAlias($value.value)) {
    logger.error({
      group: 'parser',
      label: 'validate',
      message: `Invalid alias: ${print($value)}`,
      filename,
      node: $value,
      src,
    });
  }
}

/** Verify a Border token is valid */
export function validateBorder($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
  if ($value.type !== 'Object') {
    logger.error({
      group: 'parser',
      label: 'validate',
      message: `Expected object, received ${$value.type}`,
      filename,
      node: $value,
      src,
    });
  } else {
    validateMembersAs(
      $value,
      {
        color: { validator: validateColor, required: true },
        style: { validator: validateStrokeStyle, required: true },
        width: { validator: validateDimension, required: true },
      },
      node,
      { filename, src, logger },
    );
  }
}

/** Verify a Color token is valid */
export function validateColor($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
  const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
  if ($value.type === 'String') {
    // TODO: enable when object notation is finalized
    // logger.warn({
    //   filename,
    //   message: 'String colors are no longer recommended; please use the object notation instead.',
    //   node: $value,
    //   src,
    // });
    if ($value.value === '') {
      logger.error({ ...baseMessage, message: 'Expected color, received empty string' });
    }
  } else if ($value.type === 'Object') {
    // allow "channels" but raise warning. also rename as a workaround (mutating the AST is a bad idea in general, but this is safe)
    const channelMemberI = $value.members.findIndex((m) => m.name.type === 'String' && m.name.value === 'channels');

    if (channelMemberI !== -1) {
      logger.warn({ ...baseMessage, message: '"channels" is deprecated; rename "channels" to "components"' });
      ($value.members[channelMemberI]!.name as StringNode).value = 'components';
    }

    validateMembersAs(
      $value,
      {
        colorSpace: {
          validator: (v) => {
            if (v.type !== 'String') {
              logger.error({
                ...baseMessage,
                message: `Expected string, received ${print(v)}`,
                node: v,
              });
            }
            if (!VALID_COLORSPACES.has((v as StringNode).value)) {
              logger.error({
                ...baseMessage,
                message: `Unsupported colorspace ${print(v)}`,
                node: v,
              });
            }
          },
          required: true,
        },
        components: {
          validator: (v) => {
            if (v.type !== 'Array') {
              logger.error({
                ...baseMessage,
                message: `Expected array, received ${print(v)}`,
                node: v,
              });
            } else {
              // note: in the future, length will change depending on colorSpace, e.g. CMYK
              // but in the current spec it’s 3 for now.
              if (v.elements?.length !== 3) {
                logger.error({
                  ...baseMessage,
                  message: `Expected 3 components, received ${v.elements?.length ?? 0}`,
                  node: v,
                });
              }
              for (const element of v.elements) {
                if (element.value.type !== 'Number') {
                  logger.error({
                    ...baseMessage,
                    message: `Expected number, received ${print(element.value)}`,
                    node: element,
                  });
                }
              }
            }
          },
          required: true,
        },
        hex: {
          validator: (v) => {
            if (
              v.type !== 'String' ||
              // this is a weird one—with the RegEx we test, it will work for
              // lengths of 3, 4, 6, and 8 (but not 5 or 7). So we check length
              // here, to keep the RegEx simple and readable. The "+ 1" is just
              // accounting for the '#' prefix character.
              v.value.length === 5 + 1 ||
              v.value.length === 7 + 1 ||
              !/^#[a-f0-9]{3,8}$/i.test(v.value)
            ) {
              logger.error({
                ...baseMessage,
                message: `Invalid hex color ${print(v)}`,
                node: v,
              });
            }
          },
        },
        alpha: { validator: validateNumber },
      },
      node,
      { filename, src, logger },
    );
  } else {
    logger.error({
      ...baseMessage,
      message: `Expected object, received ${$value.type}`,
      node: $value,
    });
  }
}

/** Verify a Cubic Bézier token is valid */
export function validateCubicBezier($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
  const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
  if ($value.type !== 'Array') {
    logger.error({ ...baseMessage, message: `Expected array of numbers, received ${print($value)}` });
  } else if (!$value.elements.every((e) => e.value.type === 'Number')) {
    logger.error({ ...baseMessage, message: 'Expected an array of 4 numbers, received some non-numbers' });
  } else if ($value.elements.length !== 4) {
    logger.error({ ...baseMessage, message: `Expected an array of 4 numbers, received ${$value.elements.length}` });
  }
}

/** Verify a Dimension token is valid */
export function validateDimension($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
  if ($value.type === 'Number' && $value.value === 0) {
    return; // `0` is a valid number
  }

  const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };

  // Give priority to object notation because it’s a faster code path
  if ($value.type === 'Object') {
    const { value, unit } = getObjMembers($value);
    if (!value) {
      logger.error({ ...baseMessage, message: 'Missing required property "value".' });
    }
    if (!unit) {
      logger.error({ ...baseMessage, message: 'Missing required property "unit".' });
    }
    if (value!.type !== 'Number') {
      logger.error({
        ...baseMessage,
        message: `Expected number, received ${value!.type}`,
        node: value,
      });
    }
    if (!['px', 'em', 'rem'].includes((unit as StringNode).value)) {
      logger.error({
        ...baseMessage,
        message: `Expected unit "px", "em", or "rem", received ${print(unit as StringNode)}`,
        node: unit,
      });
    }
    return;
  }

  // Backwards compat: string
  if ($value.type !== 'String') {
    logger.error({ ...baseMessage, message: `Expected string, received ${$value.type}` });
  }
  const value = ($value as StringNode).value.match(/^-?[0-9.]+/)?.[0];
  const unit = ($value as StringNode).value.replace(value!, '');
  if (($value as StringNode).value === '') {
    logger.error({ ...baseMessage, message: 'Expected dimension, received empty string' });
  } else if (!['px', 'em', 'rem'].includes(unit)) {
    logger.error({
      ...baseMessage,
      message: `Expected unit "px", "em", or "rem", received ${JSON.stringify(unit || ($value as StringNode).value)}`,
    });
  } else if (!Number.isFinite(Number.parseFloat(value!))) {
    logger.error({ ...baseMessage, message: `Expected dimension with units, received ${print($value)}` });
  }
}

/** Verify a Duration token is valid */
export function validateDuration($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
  if ($value.type === 'Number' && $value.value === 0) {
    return; // `0` is a valid number
  }

  const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };

  // Give priority to object notation because it’s a faster code path
  if ($value.type === 'Object') {
    const { value, unit } = getObjMembers($value);
    if (!value) {
      logger.error({ ...baseMessage, message: 'Missing required property "value".' });
    }
    if (!unit) {
      logger.error({ ...baseMessage, message: 'Missing required property "unit".' });
    }
    if (value?.type !== 'Number') {
      logger.error({
        ...baseMessage,
        message: `Expected number, received ${value?.type}`,
        node: value,
      });
    }
    if (!['ms', 's'].includes((unit as StringNode).value)) {
      logger.error({
        ...baseMessage,
        message: `Expected unit "ms" or "s", received ${print(unit!)}`,
        node: unit,
      });
    }
    return;
  }

  // Backwards compat: string
  if ($value.type !== 'String') {
    logger.error({ ...baseMessage, message: `Expected string, received ${$value.type}` });
  }
  const value = ($value as StringNode).value.match(/^-?[0-9.]+/)?.[0]!;
  const unit = ($value as StringNode).value.replace(value, '');
  if (($value as StringNode).value === '') {
    logger.error({ ...baseMessage, message: 'Expected duration, received empty string' });
  } else if (!['ms', 's'].includes(unit)) {
    logger.error({
      ...baseMessage,
      message: `Expected unit "ms" or "s", received ${JSON.stringify(unit || ($value as StringNode).value)}`,
    });
  } else if (!Number.isFinite(Number.parseFloat(value))) {
    logger.error({ ...baseMessage, message: `Expected duration with units, received ${print($value)}` });
  }
}

/**  Verify a Font Family token is valid */
export function validateFontFamily($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
  const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
  if ($value.type !== 'String' && $value.type !== 'Array') {
    logger.error({ ...baseMessage, message: `Expected string or array of strings, received ${$value.type}` });
  }
  if ($value.type === 'String' && $value.value === '') {
    logger.error({ ...baseMessage, message: 'Expected font family name, received empty string' });
  }
  if ($value.type === 'Array' && !$value.elements.every((e) => e.value.type === 'String' && e.value.value !== '')) {
    logger.error({
      ...baseMessage,
      message: 'Expected an array of strings, received some non-strings or empty strings',
    });
  }
}

/** Verify a Font Weight token is valid */
export function validateFontWeight($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
  const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
  if ($value.type !== 'String' && $value.type !== 'Number') {
    logger.error({ ...baseMessage, message: `Expected a font weight name or number 0–1000, received ${$value.type}` });
  }
  if ($value.type === 'String' && !FONT_WEIGHT_VALUES.has($value.value)) {
    logger.error({
      ...baseMessage,
      message: `Unknown font weight ${print($value)}. Expected one of: ${listFormat.format([...FONT_WEIGHT_VALUES])}.`,
    });
  }
  if ($value.type === 'Number' && ($value.value < 0 || $value.value > 1000)) {
    logger.error({ ...baseMessage, message: `Expected number 0–1000, received ${print($value)}` });
  }
}

/** Verify a Gradient token is valid */
export function validateGradient($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
  const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };

  if ($value.type !== 'Array') {
    logger.error({ ...baseMessage, message: `Expected array of gradient stops, received ${$value.type}` });
  } else {
    for (let i = 0; i < $value.elements.length; i++) {
      const element = $value.elements[i]!;
      if (element.value.type !== 'Object') {
        logger.error({
          ...baseMessage,
          message: `Stop #${i + 1}: Expected gradient stop, received ${element.value.type}`,
          node: element,
        });
        break;
      }
      validateMembersAs(
        element.value,
        {
          color: { validator: validateColor, required: true },
          position: { validator: validateNumber, required: true },
        },
        element,
        { filename, src, logger },
      );
    }
  }
}

/** Verify a Number token is valid */
export function validateNumber($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
  if ($value.type !== 'Number') {
    logger.error({
      group: 'parser',
      label: 'validate',
      message: `Expected number, received ${$value.type}`,
      filename,
      node: $value,
      src,
    });
  }
}

/** Verify a Boolean token is valid */
export function validateBoolean($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
  if ($value.type !== 'Boolean') {
    logger.error({
      group: 'parser',
      label: 'validate',
      message: `Expected boolean, received ${$value.type}`,
      filename,
      node: $value,
      src,
    });
  }
}

/** Verify a Shadow token’s value is valid */
export function validateShadowLayer($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
  if ($value.type !== 'Object') {
    logger.error({
      group: 'parser',
      label: 'validate',
      message: `Expected Object, received ${$value.type}`,
      filename,
      node: $value,
      src,
    });
  } else {
    validateMembersAs(
      $value,
      {
        color: { validator: validateColor, required: true },
        offsetX: { validator: validateDimension, required: true },
        offsetY: { validator: validateDimension, required: true },
        blur: { validator: validateDimension },
        spread: { validator: validateDimension },
        inset: { validator: validateBoolean },
      },
      node,
      { filename, src, logger },
    );
  }
}

/** Verify a Stroke Style token is valid. */
export function validateStrokeStyle($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
  const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };

  // note: strokeStyle’s values are NOT aliasable (unless by string, but that breaks validations)
  if ($value.type === 'String') {
    if (!STROKE_STYLE_VALUES.has($value.value)) {
      logger.error({
        ...baseMessage,
        message: `Unknown stroke style ${print($value)}. Expected one of: ${listFormat.format([
          ...STROKE_STYLE_VALUES,
        ])}.`,
      });
    }
  } else if ($value.type === 'Object') {
    const strokeMembers = getObjMembers($value);
    for (const property of ['dashArray', 'lineCap']) {
      if (!strokeMembers[property]) {
        logger.error({ ...baseMessage, message: `Missing required property "${property}"` });
      }
    }
    const { lineCap, dashArray } = strokeMembers;
    if (lineCap?.type !== 'String' || !STROKE_STYLE_LINE_CAP_VALUES.has(lineCap.value)) {
      logger.error({
        ...baseMessage,
        message: `Unknown lineCap value ${print(lineCap!)}. Expected one of: ${listFormat.format([
          ...STROKE_STYLE_LINE_CAP_VALUES,
        ])}.`,
        node,
      });
    }
    if (dashArray?.type === 'Array') {
      for (const element of dashArray.elements) {
        if (element.value.type === 'String' && element.value.value !== '') {
          if (isMaybeAlias(element.value)) {
            validateAliasSyntax(element.value, node, { logger, src });
          } else {
            validateDimension(element.value, node, { logger, src });
          }
        } else {
          logger.error({
            ...baseMessage,
            message: 'Expected array of strings, recieved some non-strings or empty strings.',
            node: element,
          });
        }
      }
    } else {
      logger.error({ ...baseMessage, message: `Expected array of strings, received ${dashArray!.type}` });
    }
  } else {
    logger.error({ ...baseMessage, message: `Expected string or object, received ${$value.type}` });
  }
}

/** Verify a Transition token is valid */
export function validateTransition($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
  if ($value.type !== 'Object') {
    logger.error({
      group: 'parser',
      label: 'validate',
      message: `Expected object, received ${$value.type}`,
      filename,
      node: $value,
      src,
    });
  } else {
    validateMembersAs(
      $value,
      {
        duration: { validator: validateDuration, required: true },
        delay: { validator: validateDuration, required: false }, // note: spec says delay is required, but Terrazzo makes delay optional
        timingFunction: { validator: validateCubicBezier, required: true },
      },
      node,
      { filename, src, logger },
    );
  }
}

/**
 * Validate a MemberNode (the entire token object, plus its key in the parent
 * object) to see if it’s a valid DTCG token or not. Keeping the parent key
 * really helps in debug messages.
 */
export function validateTokenMemberNode(node: MemberNode, { filename, src, logger }: ValidateOptions) {
  const baseMessage = { group: 'parser' as const, label: 'validate', filename, node, src };

  if (node.type !== 'Member' && node.type !== 'Object') {
    logger.error({
      ...baseMessage,
      message: `Expected Object, received ${JSON.stringify(
        // @ts-ignore Yes, TypeScript, this SHOULD be unexpected. This is why we’re validating.
        node.type,
      )}`,
    });
  }

  const rootMembers = node.value.type === 'Object' ? getObjMembers(node.value) : {};
  const $value = rootMembers.$value as ValueNode;
  const $type = rootMembers.$type as StringNode;

  if (!$value) {
    logger.error({ ...baseMessage, message: 'Token missing $value' });
  }
  // If top-level value is a valid alias, this is valid (no need for $type)
  // ⚠️ Important: ALL Object and Array nodes below will need to check for aliases within!
  if (isMaybeAlias($value)) {
    validateAliasSyntax($value, node, { logger, src });
    return;
  }

  if (!$type) {
    logger.error({ ...baseMessage, message: 'Token missing $type' });
  }

  switch ($type.value) {
    case 'color': {
      validateColor($value, node, { logger, src });
      break;
    }
    case 'cubicBezier': {
      validateCubicBezier($value, node, { logger, src });
      break;
    }
    case 'dimension': {
      validateDimension($value, node, { logger, src });
      break;
    }
    case 'duration': {
      validateDuration($value, node, { logger, src });
      break;
    }
    case 'fontFamily': {
      validateFontFamily($value, node, { logger, src });
      break;
    }
    case 'fontWeight': {
      validateFontWeight($value, node, { logger, src });
      break;
    }
    case 'number': {
      validateNumber($value, node, { logger, src });
      break;
    }
    case 'shadow': {
      if ($value.type === 'Object') {
        validateShadowLayer($value, node, { logger, src });
      } else if ($value.type === 'Array') {
        for (const element of $value.elements) {
          validateShadowLayer(element.value, $value, { logger, src });
        }
      } else {
        logger.error({
          ...baseMessage,
          message: `Expected shadow object or array of shadow objects, received ${$value.type}`,
          node: $value,
        });
      }
      break;
    }

    // extensions
    case 'boolean': {
      if ($value.type !== 'Boolean') {
        logger.error({
          ...baseMessage,
          message: `Expected boolean, received ${$value.type}`,
          node: $value,
        });
      }
      break;
    }
    case 'link': {
      if ($value.type !== 'String') {
        logger.error({
          ...baseMessage,
          message: `Expected string, received ${$value.type}`,
          node: $value,
        });
      } else if ($value.value === '') {
        logger.error({
          ...baseMessage,
          message: 'Expected URL, received empty string',
          node: $value,
        });
      }
      break;
    }
    case 'string': {
      if ($value.type !== 'String') {
        logger.error({
          ...baseMessage,
          message: `Expected string, received ${$value.type}`,
          node: $value,
        });
      }
      break;
    }

    // composite types
    case 'border': {
      validateBorder($value, node, { filename, src, logger });
      break;
    }
    case 'gradient': {
      validateGradient($value, node, { filename, src, logger });
      break;
    }
    case 'strokeStyle': {
      validateStrokeStyle($value, node, { filename, src, logger });
      break;
    }
    case 'transition': {
      validateTransition($value, node, { filename, src, logger });
      break;
    }
    case 'typography': {
      if ($value.type !== 'Object') {
        logger.error({
          ...baseMessage,
          message: `Expected object, received ${$value.type}`,
          node: $value,
        });
        break;
      }
      if ($value.members.length === 0) {
        logger.error({
          ...baseMessage,
          message: 'Empty typography token. Must contain at least 1 property.',
          node: $value,
        });
      }
      validateMembersAs(
        $value,
        {
          fontFamily: { validator: validateFontFamily },
          fontWeight: { validator: validateFontWeight },
        },
        node,
        { filename, src, logger },
      );
      break;
    }

    default: {
      // noop
      break;
    }
  }
}

export interface ValidateTokenNodeOptions {
  subpath: string[];
  src: string;
  filename: URL;
  config: ConfigInit;
  logger: Logger;
  parent?: AnyNode;
  $typeInheritance?: Record<string, Token['$type']>;
}

/**
 * Validate does a little more than validate; it also converts to TokenNormalized
 * and sets up the basic data structure. But aliases are unresolved, and we need
 * a 2nd normalization pass afterward.
 */
export default function validateTokenNode(
  node: MemberNode,
  { config, filename, logger, parent, src, subpath, $typeInheritance }: ValidateTokenNodeOptions,
): TokenNormalized | undefined {
  // const start = performance.now();

  // don’t validate $value
  if (subpath.includes('$value') || node.value.type !== 'Object') {
    return;
  }

  const members = getObjMembers(node.value);

  // keep track of $types
  if ($typeInheritance && members.$type && members.$type.type === 'String' && !members.$value) {
    // @ts-ignore
    $typeInheritance[subpath.join('.') || '.'] = node.value.members.find((m) => m.name.value === '$type');
  }

  // don’t validate $extensions or $defs
  if (!members.$value || subpath.includes('$extensions') || subpath.includes('$deps')) {
    return;
  }

  const id = subpath.join('.');

  if (!subpath.includes('.$value') && members.value) {
    logger.warn({
      group: 'parser',
      label: 'validate',
      message: `Group ${id} has "value". Did you mean "$value"?`,
      filename,
      node,
      src,
    });
  }

  const extensions = members.$extensions ? getObjMembers(members.$extensions as ObjectNode) : undefined;
  const sourceNode = structuredClone(node);

  // get parent type by taking the closest-scoped $type (length === closer)
  let parent$type: Token['$type'] | undefined;
  let longestPath = '';
  for (const [k, v] of Object.entries($typeInheritance ?? {})) {
    if (k === '.' || id.startsWith(k)) {
      if (k.length > longestPath.length) {
        parent$type = v;
        longestPath = k;
      }
    }
  }
  if (parent$type && !members.$type) {
    injectObjMembers(
      // @ts-ignore
      sourceNode.value,
      [parent$type],
    );
  }

  validateTokenMemberNode(sourceNode, { filename, src, logger });

  // All tokens must be valid, so we want to validate it up till this
  // point. However, if we are ignoring this token (or respecting
  // $deprecated, we can omit it from the output.
  const $deprecated = members.$deprecated && (evaluate(members.$deprecated) as string | boolean | undefined);
  if ((config.ignore.deprecated && $deprecated) || (config.ignore.tokens && isTokenMatch(id, config.ignore.tokens))) {
    return;
  }

  const group: TokenNormalized['group'] = { id: splitID(id).group!, tokens: [] };
  if (parent$type) {
    group.$type =
      // @ts-ignore
      parent$type.value.value;
  }
  // note: this will also include sibling tokens, so be selective about only accessing group-specific properties
  const groupMembers = getObjMembers(
    // @ts-ignore
    parent,
  );
  if (groupMembers.$description) {
    group.$description = evaluate(groupMembers.$description) as string;
  }
  if (groupMembers.$extensions) {
    group.$extensions = evaluate(groupMembers.$extensions) as Record<string, unknown>;
  }
  const token: TokenNormalized = {
    // @ts-ignore
    $type: members.$type?.value ?? parent$type?.value.value,
    // @ts-ignore
    $value: evaluate(members.$value),
    id,
    // @ts-ignore
    mode: {},
    // @ts-ignore
    originalValue: evaluate(node.value),
    group,
    source: {
      loc: filename ? filename.href : undefined,
      // @ts-ignore
      node: sourceNode.value,
    },
  };
  // @ts-ignore
  if (members.$description?.value) {
    // @ts-ignore
    token.$description = members.$description.value;
  }

  // handle modes
  // note that circular refs are avoided here, such as not duplicating `modes`
  const modeValues = extensions?.mode ? getObjMembers(extensions.mode as any) : {};
  for (const mode of ['.', ...Object.keys(modeValues)]) {
    const modeValue = mode === '.' ? token.$value : (evaluate((modeValues as any)[mode]) as any);
    token.mode[mode] = {
      $value: modeValue,
      originalValue: modeValue,
      source: {
        loc: filename ? filename.href : undefined,
        // @ts-ignore
        node: modeValues[mode],
      },
    };
  }

  // logger.debug({
  //   message: `${token.id}: validateTokenNode`,
  //   group: 'parser', label: 'validate',
  //   label: 'validate',
  //   timing: performance.now() - start,
  // });
  return token;
}
