import * as ed5 from "xmlchars/xml/1.0/ed5";
import * as ed2 from "xmlchars/xml/1.1/ed2";
import * as NSed3 from "xmlchars/xmlns/1.0/ed3";

import isS = ed5.isS;
import isChar10 = ed5.isChar;
import isNameStartChar = ed5.isNameStartChar;
import isNameChar = ed5.isNameChar;
import S_LIST = ed5.S_LIST;
import NAME_RE = ed5.NAME_RE;

import isChar11 = ed2.isChar;

import isNCNameStartChar = NSed3.isNCNameStartChar;
import isNCNameChar = NSed3.isNCNameChar;
import NC_NAME_RE = NSed3.NC_NAME_RE;

const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
const XMLNS_NAMESPACE = "http://www.w3.org/2000/xmlns/";

const rootNS: Record<string, string> = {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  __proto__: null as any,
  xml: XML_NAMESPACE,
  xmlns: XMLNS_NAMESPACE,
};

const XML_ENTITIES: Record<string, string> = {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  __proto__: null as any,
  amp: "&",
  gt: ">",
  lt: "<",
  quot: "\"",
  apos: "'",
};

// EOC: end-of-chunk
const EOC = -1;
const NL_LIKE = -2;

const S_BEGIN = 0; // Initial state.
const S_BEGIN_WHITESPACE = 1; // leading whitespace
const S_DOCTYPE = 2; // <!DOCTYPE
const S_DOCTYPE_QUOTE = 3; // <!DOCTYPE "//blah
const S_DTD = 4; // <!DOCTYPE "//blah" [ ...
const S_DTD_QUOTED = 5; // <!DOCTYPE "//blah" [ "foo
const S_DTD_OPEN_WAKA = 6;
const S_DTD_OPEN_WAKA_BANG = 7;
const S_DTD_COMMENT = 8; // <!--
const S_DTD_COMMENT_ENDING = 9; // <!-- blah -
const S_DTD_COMMENT_ENDED = 10; // <!-- blah --
const S_DTD_PI = 11; // <?
const S_DTD_PI_ENDING = 12; // <?hi "there" ?
const S_TEXT = 13; // general stuff
const S_ENTITY = 14; // &amp and such
const S_OPEN_WAKA = 15; // <
const S_OPEN_WAKA_BANG = 16; // <!...
const S_COMMENT = 17; // <!--
const S_COMMENT_ENDING = 18; // <!-- blah -
const S_COMMENT_ENDED = 19; // <!-- blah --
const S_CDATA = 20; // <![CDATA[ something
const S_CDATA_ENDING = 21; // ]
const S_CDATA_ENDING_2 = 22; // ]]
const S_PI_FIRST_CHAR = 23; // <?hi, first char
const S_PI_REST = 24; // <?hi, rest of the name
const S_PI_BODY = 25; // <?hi there
const S_PI_ENDING = 26; // <?hi "there" ?
const S_XML_DECL_NAME_START = 27; // <?xml
const S_XML_DECL_NAME = 28; // <?xml foo
const S_XML_DECL_EQ = 29; // <?xml foo=
const S_XML_DECL_VALUE_START = 30; // <?xml foo=
const S_XML_DECL_VALUE = 31; // <?xml foo="bar"
const S_XML_DECL_SEPARATOR = 32; // <?xml foo="bar"
const S_XML_DECL_ENDING = 33; // <?xml ... ?
const S_OPEN_TAG = 34; // <strong
const S_OPEN_TAG_SLASH = 35; // <strong /
const S_ATTRIB = 36; // <a
const S_ATTRIB_NAME = 37; // <a foo
const S_ATTRIB_NAME_SAW_WHITE = 38; // <a foo _
const S_ATTRIB_VALUE = 39; // <a foo=
const S_ATTRIB_VALUE_QUOTED = 40; // <a foo="bar
const S_ATTRIB_VALUE_CLOSED = 41; // <a foo="bar"
const S_ATTRIB_VALUE_UNQUOTED = 42; // <a foo=bar
const S_CLOSE_TAG = 43; // </a
const S_CLOSE_TAG_SAW_WHITE = 44; // </a   >

const TAB = 9;
const NL = 0xA;
const CR = 0xD;
const SPACE = 0x20;
const BANG = 0x21;
const DQUOTE = 0x22;
const AMP = 0x26;
const SQUOTE = 0x27;
const MINUS = 0x2D;
const FORWARD_SLASH = 0x2F;
const SEMICOLON = 0x3B;
const LESS = 0x3C;
const EQUAL = 0x3D;
const GREATER = 0x3E;
const QUESTION = 0x3F;
const OPEN_BRACKET = 0x5B;
const CLOSE_BRACKET = 0x5D;
const NEL = 0x85;
const LS = 0x2028; // Line Separator

const isQuote = (c: number): boolean => c === DQUOTE || c === SQUOTE;

const QUOTES = [DQUOTE, SQUOTE];

const DOCTYPE_TERMINATOR = [...QUOTES, OPEN_BRACKET, GREATER];
const DTD_TERMINATOR = [...QUOTES, LESS, CLOSE_BRACKET];
const XML_DECL_NAME_TERMINATOR = [EQUAL, QUESTION, ...S_LIST];
const ATTRIB_VALUE_UNQUOTED_TERMINATOR = [...S_LIST, GREATER, AMP, LESS];

function nsPairCheck(parser: SaxesParser<{}>, prefix: string,
                     uri: string): void {
  switch (prefix) {
    case "xml":
      if (uri !== XML_NAMESPACE) {
        parser.fail(`xml prefix must be bound to ${XML_NAMESPACE}.`);
      }
      break;
    case "xmlns":
      if (uri !== XMLNS_NAMESPACE) {
        parser.fail(`xmlns prefix must be bound to ${XMLNS_NAMESPACE}.`);
      }
      break;
    default:
  }

  switch (uri) {
    case XMLNS_NAMESPACE:
      parser.fail(prefix === "" ?
        `the default namespace may not be set to ${uri}.` :
        `may not assign a prefix (even "xmlns") to the URI \
${XMLNS_NAMESPACE}.`);
      break;
    case XML_NAMESPACE:
      switch (prefix) {
        case "xml":
          // Assinging the XML namespace to "xml" is fine.
          break;
        case "":
          parser.fail(`the default namespace may not be set to ${uri}.`);
          break;
        default:
          parser.fail("may not assign the xml namespace to another prefix.");
      }
      break;
    default:
  }
}


function nsMappingCheck(parser: SaxesParser<{}>,
                        mapping: Record<string, string>): void {
  for (const local of Object.keys(mapping)) {
    nsPairCheck(parser, local, mapping[local]);
  }
}

const isNCName = (name: string): boolean => NC_NAME_RE.test(name);

const isName = (name: string): boolean => NAME_RE.test(name);

const FORBIDDEN_START = 0;
const FORBIDDEN_BRACKET = 1;
const FORBIDDEN_BRACKET_BRACKET = 2;

/**
 * The list of supported events.
 */
export const EVENTS = [
  "xmldecl",
  "text",
  "processinginstruction",
  "doctype",
  "comment",
  "opentagstart",
  "attribute",
  "opentag",
  "closetag",
  "cdata",
  "error",
  "end",
  "ready",
] as const;

const EVENT_NAME_TO_HANDLER_NAME: Record<EventName, string> = {
  xmldecl: "xmldeclHandler",
  text: "textHandler",
  processinginstruction: "piHandler",
  doctype: "doctypeHandler",
  comment: "commentHandler",
  opentagstart: "openTagStartHandler",
  attribute: "attributeHandler",
  opentag: "openTagHandler",
  closetag: "closeTagHandler",
  cdata: "cdataHandler",
  error: "errorHandler",
  end: "endHandler",
  ready: "readyHandler",
};

/**
 * Event handler for the
 *
 * @param text The text data encountered by the parser.
 *
 */
export type XMLDeclHandler = (decl: XMLDecl) => void;

/**
 * Event handler for text data.
 *
 * @param text The text data encountered by the parser.
 *
 */
export type TextHandler = (text: string) => void;

/**
 * Event handler for processing instructions.
 *
 * @param data The target and body of the processing instruction.
 */
export type PIHandler = (data: { target: string; body: string }) => void;


/**
 * Event handler for doctype.
 *
 * @param doctype The doctype contents.
 */
export type DoctypeHandler = (doctype: string) => void;

/**
 * Event handler for comments.
 *
 * @param comment The comment contents.
 */
export type CommentHandler = (comment: string) => void;

/**
 * Event handler for the start of an open tag. This is called as soon as we
 * have a tag name.
 *
 * @param tag The tag.
 */
export type OpenTagStartHandler<O> = (tag: StartTagForOptions<O>) => void;

export type AttributeEventForOptions<O extends SaxesOptions> =
  O extends { xmlns: true } ? SaxesAttributeNSIncomplete :
    O extends { xmlns?: false | undefined } ? SaxesAttributePlain :
      SaxesAttribute;

/**
 * Event handler for attributes.
 */
export type AttributeHandler<O> =
  (attribute: AttributeEventForOptions<O>) => void;

/**
 * Event handler for an open tag. This is called when the open tag is
 * complete. (We've encountered the ">" that ends the open tag.)
 *
 * @param tag The tag.
 */
export type OpenTagHandler<O> = (tag: TagForOptions<O>) => void;

/**
 * Event handler for a close tag. Note that for self-closing tags, this is
 * called right after ``opentag``.
 *
 * @param tag The tag.
 */
export type CloseTagHandler<O> = (tag: TagForOptions<O>) => void;

/**
 * Event handler for a CDATA section. This is called when ending the
 * CDATA section.
 *
 * @param cdata The contents of the CDATA section.
 */
export type CDataHandler = (cdata: string) => void;

/**
 * Event handler for the stream end. This is called when the stream has been
 * closed with ``close`` or by passing ``null`` to ``write``.
 */
export type EndHandler = () => void;

/**
 * Event handler indicating parser readiness . This is called when the parser
 * is ready to parse a new document.
 */
export type ReadyHandler = () => void;

/**
 * Event handler indicating an error.
 *
 * @param err The error that occurred.
 */
export type ErrorHandler = (err: Error) => void;

export type EventName = (typeof EVENTS)[number];
export type EventNameToHandler<O, N extends EventName> = {
  "xmldecl": XMLDeclHandler;
  "text": TextHandler;
  "processinginstruction": PIHandler;
  "doctype": DoctypeHandler;
  "comment": CommentHandler;
  "opentagstart": OpenTagStartHandler<O>;
  "attribute": AttributeHandler<O>;
  "opentag": OpenTagHandler<O>;
  "closetag": CloseTagHandler<O>;
  "cdata": CDataHandler;
  "error": ErrorHandler;
  "end": EndHandler;
  "ready": ReadyHandler;
}[N];

/**
 * This interface defines the structure of attributes when the parser is
 * processing namespaces (created with ``xmlns: true``).
 */
export interface SaxesAttributeNS {
  /**
   * The attribute's name. This is the combination of prefix and local name.
   * For instance ``a:b="c"`` would have ``a:b`` for name.
   */
  name: string;

  /**
   * The attribute's prefix. For instance ``a:b="c"`` would have ``"a"`` for
   * ``prefix``.
   */
  prefix: string;

  /**
   * The attribute's local name. For instance ``a:b="c"`` would have ``"b"`` for
   * ``local``.
   */
  local: string;

  /** The namespace URI of this attribute. */
  uri: string;

  /** The attribute's value. */
  value: string;
}

/**
 * This is an attribute, as recorded by a parser which parses namespaces but
 * prior to the URI being resolvable. This is what is passed to the attribute
 * event handler.
 */
export type SaxesAttributeNSIncomplete = Exclude<SaxesAttributeNS, "uri">;

/**
 * This interface defines the structure of attributes when the parser is
 * NOT processing namespaces (created with ``xmlns: false``).
 */
export interface SaxesAttributePlain {
  /**
   * The attribute's name.
   */
  name: string;

  /** The attribute's value. */
  value: string;
}

/**
 * A saxes attribute, with or without namespace information.
 */
export type SaxesAttribute = SaxesAttributeNS | SaxesAttributePlain;

/**
 * This are the fields that MAY be present on a complete tag.
 */
export interface SaxesTag {
  /**
   * The tag's name. This is the combination of prefix and global name. For
   * instance ``<a:b>`` would have ``"a:b"`` for ``name``.
   */
  name: string;

  /**
   * A map of attribute name to attributes. If namespaces are tracked, the
   * values in the map are attribute objects. Otherwise, they are strings.
   */
  attributes: Record<string, SaxesAttributeNS> | Record<string, string>;

  /**
   * The namespace bindings in effect.
   */
  ns?: Record<string, string>;

  /**
   * The tag's prefix. For instance ``<a:b>`` would have ``"a"`` for
   * ``prefix``. Undefined if we do not track namespaces.
   */
  prefix?: string;

  /**
   * The tag's local name. For instance ``<a:b>`` would
   * have ``"b"`` for ``local``. Undefined if we do not track namespaces.
   */
  local?: string;

  /**
   * The namespace URI of this tag. Undefined if we do not track namespaces.
   */
  uri?: string;

  /** Whether the tag is self-closing (e.g. ``<foo/>``). */
  isSelfClosing: boolean;
}

/**
 * This type defines the fields that are present on a tag object when
 * ``onopentagstart`` is called. This interface is namespace-agnostic.
 */
export type SaxesStartTag = Pick<SaxesTag, "name" | "attributes" | "ns">;

/**
 * This type defines the fields that are present on a tag object when
 * ``onopentagstart`` is called on a parser that does not processes namespaces.
 */
export type SaxesStartTagPlain = Pick<SaxesStartTag, "name" | "attributes">;

/**
 * This type defines the fields that are present on a tag object when
 * ``onopentagstart`` is called on a parser that does process namespaces.
 */
export type SaxesStartTagNS = Required<SaxesStartTag>;

/**
 * This are the fields that are present on a complete tag produced by a parser
 * that does process namespaces.
 */
export type SaxesTagNS = Required<SaxesTag> & {
  attributes: Record<string, SaxesAttributeNS>;
};

/**
 * This are the fields that are present on a complete tag produced by a parser
 * that does not process namespaces.
 */
export type SaxesTagPlain =
  Pick<SaxesTag, "name" | "attributes" | "isSelfClosing"> & {
    attributes: Record<string, string>;
  };

// This is an internal type used for holding tags while they are being built.
type SaxesTagIncomplete =
  Omit<SaxesTag, "isSelfClosing"> & Partial<Pick<SaxesTag, "isSelfClosing">>;

/**
 * An XML declaration.
 */
export interface XMLDecl {
  /** The version specified by the XML declaration. */
  version?: string;

  /** The encoding specified by the XML declaration. */
  encoding?: string;

  /** The value of the standalone parameter */
  standalone?: string;
}

/**
 * A callback for resolving name prefixes.
 *
 * @param prefix The prefix to check.
 *
 * @returns The URI corresponding to the prefix, if any.
 */
export type ResolvePrefix = (prefix: string) => string | undefined;

export interface CommonOptions {
  /** Whether to accept XML fragments. Unset means ``false``. */
  fragment?: boolean;

  /** Whether to track positions. Unset means ``true``. */
  position?: boolean;

  /**
   * A file name to use for error reporting. "File name" is a loose concept. You
   * could use a URL to some resource, or any descriptive name you like.
   */
  fileName?: string;
}

export interface NSOptions {
  /** Whether to track namespaces. Unset means ``false``. */
  xmlns?: boolean;

  /**
   * A plain object whose key, value pairs define namespaces known before
   * parsing the XML file. It is not legal to pass bindings for the namespaces
   * ``"xml"`` or ``"xmlns"``.
   */
  additionalNamespaces?: Record<string, string>;

  /**
   * A function that will be used if the parser cannot resolve a namespace
   * prefix on its own.
   */
  resolvePrefix?: ResolvePrefix;
}

export interface NSOptionsWithoutNamespaces extends NSOptions {
  xmlns?: false;
  // It makes no sense to set these if namespaces are not used.
  additionalNamespaces?: undefined;
  resolvePrefix?: undefined;
}

export interface NSOptionsWithNamespaces extends NSOptions {
  xmlns: true;
  // The other options are still optional.
}

export interface XMLVersionOptions {
  /**
   * The default XML version to use. If unspecified, and there is no XML
   * encoding declaration, the default version is "1.0".
   */
  defaultXMLVersion?: "1.0" | "1.1";

  /**
   * A flag indicating whether to force the XML version used for parsing to the
   * value of ``defaultXMLVersion``. When this flag is ``true``,
   * ``defaultXMLVersion`` must be specified. If unspecified, the default value
   * of this flag is ``false``.
   */
  forceXMLVersion?: boolean;
}

export interface NoForcedXMLVersion extends XMLVersionOptions {
  forceXMLVersion?: false;
  // defaultXMLVersion stays the same.
}

export interface ForcedXMLVersion extends XMLVersionOptions {
  forceXMLVersion: true;
  // defaultXMLVersion becomes mandatory.
  defaultXMLVersion: Exclude<XMLVersionOptions["defaultXMLVersion"],
  undefined>;
}

/**
 * The entire set of options supported by saxes.
 */
export type SaxesOptions = CommonOptions & NSOptions & XMLVersionOptions;

export type TagForOptions<O extends SaxesOptions> =
  O extends { xmlns: true } ? SaxesTagNS :
    O extends { xmlns?: false | undefined } ? SaxesTagPlain :
      SaxesTag;

export type StartTagForOptions<O extends SaxesOptions> =
  O extends { xmlns: true } ? SaxesStartTagNS :
    O extends { xmlns?: false | undefined } ? SaxesStartTagPlain :
      SaxesStartTag;

export class SaxesParser<O extends SaxesOptions = {}> {
  private readonly fragmentOpt: boolean;
  private readonly xmlnsOpt: boolean;
  private readonly trackPosition: boolean;
  private readonly fileName?: string;
  private readonly nameStartCheck: (c: number) => boolean;
  private readonly nameCheck: (c: number) => boolean;
  private readonly isName: (name: string) => boolean;
  private readonly ns!: Record<string, string>;

  private openWakaBang!: string;
  private text!: string;
  private name!: string;
  private piTarget!: string;
  private entity!: string;
  private q!: null | number;
  private tags!: SaxesTagIncomplete[];
  private tag!: SaxesTagIncomplete | null;
  private topNS!: Record<string, string> | null;
  private chunk!: string;
  private chunkPosition!: number;
  private i!: number;

  //
  // We use prevI to allow "ungetting" the previously read code point. Note
  // however, that it is not safe to unget everything and anything. In
  // particular ungetting EOL characters will screw positioning up.
  //
  // Practically, you must not unget a code which has any side effect beyond
  // updating ``this.i`` and ``this.prevI``. Only EOL codes have such side
  // effects.
  //
  private prevI!: number;
  private carriedFromPrevious?: string;
  private forbiddenState!: number;
  private attribList!: (SaxesAttributeNSIncomplete | SaxesAttributePlain)[];
  private state!: number;
  private reportedTextBeforeRoot!: boolean;
  private reportedTextAfterRoot!: boolean;
  private closedRoot!: boolean;
  private sawRoot!: boolean;
  private xmlDeclPossible!: boolean;
  private xmlDeclExpects!: string[];
  private entityReturnState?: number;
  private processAttribs!: (this: this) => void;
  private positionAtNewLine!: number;
  private doctype!: boolean;
  private getCode!: () => number;
  private isChar!: (c: number) => boolean;
  private pushAttrib!: (name: string, value: string) => void;
  private _closed!: boolean;
  private currentXMLVersion!: string;
  private readonly stateTable: ((this: SaxesParser<O>) => void)[];
  private xmldeclHandler?: XMLDeclHandler;
  private textHandler?: TextHandler;
  private piHandler?: PIHandler;
  private doctypeHandler?: DoctypeHandler;
  private commentHandler?: CommentHandler;
  private openTagStartHandler?: OpenTagStartHandler<O>;
  private openTagHandler?: OpenTagHandler<O>;
  private closeTagHandler?: CloseTagHandler<O>;
  private cdataHandler?: CDataHandler;
  private errorHandler?: ErrorHandler;
  private endHandler?: EndHandler;
  private readyHandler?: ReadyHandler;
  private attributeHandler?: AttributeHandler<O>;

  /**
   * Indicates whether or not the parser is closed. If ``true``, wait for
   * the ``ready`` event to write again.
   */
  get closed(): boolean {
    return this._closed;
  }

  readonly opt: SaxesOptions;

  /**
   * The XML declaration for this document.
   */
  xmlDecl!: XMLDecl;

  /**
   * The line number of the next character to be read by the parser. This field
   * is one-based. (The first line is numbered 1.)
   */
  line!: number;

  /**
   * The column number of the next character to be read by the parser.  *
   * This field is zero-based. (The first column is 0.)
   *
   * This field counts columns by *Unicode character*. Note that this *can*
   * be different from the index of the character in a JavaScript string due
   * to how JavaScript handles astral plane characters.
   *
   * See [[columnIndex]] for a number that corresponds to the JavaScript index.
   */
  column!: number;

  /**
   * A map of entity name to expansion.
   */
  ENTITIES!: Record<string, string>;

  /**
   * @param opt The parser options.
   */
  constructor(opt?: O) {
    this.opt = opt ?? {};
    this.fragmentOpt = !!(this.opt.fragment as boolean);
    const xmlnsOpt = this.xmlnsOpt = !!(this.opt.xmlns as boolean);
    this.trackPosition = this.opt.position !== false;
    this.fileName = this.opt.fileName;

    if (xmlnsOpt) {
      // This is the function we use to perform name checks on PIs and entities.
      // When namespaces are used, colons are not allowed in PI target names or
      // entity names. So the check depends on whether namespaces are used. See:
      //
      // https://www.w3.org/XML/xml-names-19990114-errata.html
      // NE08
      //
      this.nameStartCheck = isNCNameStartChar;
      this.nameCheck = isNCNameChar;
      this.isName = isNCName;
      // eslint-disable-next-line @typescript-eslint/unbound-method
      this.processAttribs = this.processAttribsNS;
      // eslint-disable-next-line @typescript-eslint/unbound-method
      this.pushAttrib = this.pushAttribNS;


      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      this.ns = { __proto__: null as any, ...rootNS };
      const additional = this.opt.additionalNamespaces;
      if (additional != null) {
        nsMappingCheck(this, additional);
        Object.assign(this.ns, additional);
      }
    }
    else {
      this.nameStartCheck = isNameStartChar;
      this.nameCheck = isNameChar;
      this.isName = isName;
      // eslint-disable-next-line @typescript-eslint/unbound-method
      this.processAttribs = this.processAttribsPlain;
      // eslint-disable-next-line @typescript-eslint/unbound-method
      this.pushAttrib = this.pushAttribPlain;
    }

    //
    // The order of the members in this table needs to correspond to the state
    // numbers given to the states that correspond to the methods being recorded
    // here.
    //
    this.stateTable = [
      /* eslint-disable @typescript-eslint/unbound-method */
      this.sBegin,
      this.sBeginWhitespace,
      this.sDoctype,
      this.sDoctypeQuote,
      this.sDTD,
      this.sDTDQuoted,
      this.sDTDOpenWaka,
      this.sDTDOpenWakaBang,
      this.sDTDComment,
      this.sDTDCommentEnding,
      this.sDTDCommentEnded,
      this.sDTDPI,
      this.sDTDPIEnding,
      this.sText,
      this.sEntity,
      this.sOpenWaka,
      this.sOpenWakaBang,
      this.sComment,
      this.sCommentEnding,
      this.sCommentEnded,
      this.sCData,
      this.sCDataEnding,
      this.sCDataEnding2,
      this.sPIFirstChar,
      this.sPIRest,
      this.sPIBody,
      this.sPIEnding,
      this.sXMLDeclNameStart,
      this.sXMLDeclName,
      this.sXMLDeclEq,
      this.sXMLDeclValueStart,
      this.sXMLDeclValue,
      this.sXMLDeclSeparator,
      this.sXMLDeclEnding,
      this.sOpenTag,
      this.sOpenTagSlash,
      this.sAttrib,
      this.sAttribName,
      this.sAttribNameSawWhite,
      this.sAttribValue,
      this.sAttribValueQuoted,
      this.sAttribValueClosed,
      this.sAttribValueUnquoted,
      this.sCloseTag,
      this.sCloseTagSawWhite,
      /* eslint-enable @typescript-eslint/unbound-method */
    ];

    this._init();
  }

  _init(): void {
    this.openWakaBang = "";
    this.text = "";
    this.name = "";
    this.piTarget = "";
    this.entity = "";

    this.q = null;
    this.tags = [];
    this.tag = null;
    this.topNS = null;
    this.chunk = "";
    this.chunkPosition = 0;
    this.i = 0;
    this.prevI = 0;
    this.carriedFromPrevious = undefined;
    this.forbiddenState = FORBIDDEN_START;
    this.attribList = [];

    // The logic is organized so as to minimize the need to check
    // this.opt.fragment while parsing.

    const { fragmentOpt } = this;
    this.state = fragmentOpt ? S_TEXT : S_BEGIN;
    // We want these to be all true if we are dealing with a fragment.
    this.reportedTextBeforeRoot = this.reportedTextAfterRoot = this.closedRoot =
      this.sawRoot = fragmentOpt;
    // An XML declaration is intially possible only when parsing whole
    // documents.
    this.xmlDeclPossible = !fragmentOpt;

    this.xmlDeclExpects = ["version"];
    this.entityReturnState = undefined;

    let { defaultXMLVersion } = this.opt;
    if (defaultXMLVersion === undefined) {
      if (this.opt.forceXMLVersion === true) {
        throw new Error("forceXMLVersion set but defaultXMLVersion is not set");
      }
      defaultXMLVersion = "1.0";
    }
    this.setXMLVersion(defaultXMLVersion);

    this.positionAtNewLine = 0;

    this.doctype = false;
    this._closed = false;

    this.xmlDecl = {
      version: undefined,
      encoding: undefined,
      standalone: undefined,
    };

    this.line = 1;
    this.column = 0;

    this.ENTITIES = Object.create(XML_ENTITIES);

    // eslint-disable-next-line no-unused-expressions
    this.readyHandler?.();
  }

  /**
   * The stream position the parser is currently looking at. This field is
   * zero-based.
   *
   * This field is not based on counting Unicode characters but is to be
   * interpreted as a plain index into a JavaScript string.
   */
  get position(): number {
    return this.chunkPosition + this.i;
  }

  /**
   * The column number of the next character to be read by the parser.  *
   * This field is zero-based. (The first column in a line is 0.)
   *
   * This field reports the index at which the next character would be in the
   * line if the line were represented as a JavaScript string.  Note that this
   * *can* be different to a count based on the number of *Unicode characters*
   * due to how JavaScript handles astral plane characters.
   *
   * See [[column]] for a number that corresponds to a count of Unicode
   * characters.
   */
  get columnIndex(): number {
    return this.position - this.positionAtNewLine;
  }

  /**
   * Set an event listener on an event. The parser supports one handler per
   * event type. If you try to set an event handler over an existing handler,
   * the old handler is silently overwritten.
   *
   * @param name The event to listen to.
   *
   * @param handler The handler to set.
   */
  on<N extends EventName>(name: N, handler: EventNameToHandler<O, N>): void {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (this as any)[EVENT_NAME_TO_HANDLER_NAME[name]] = handler;
  }

  /**
   * Unset an event handler.
   *
   * @parma name The event to stop listening to.
   */
  off(name: EventName): void {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (this as any)[EVENT_NAME_TO_HANDLER_NAME[name]] = undefined;
  }

  /**
   * Make an error object. The error object will have a message that contains
   * the ``fileName`` option passed at the creation of the parser. If position
   * tracking was turned on, it will also have line and column number
   * information.
   *
   * @param message The message describing the error to report.
   *
   * @returns An error object with a properly formatted message.
   */
  makeError(message: string): Error {
    let msg = this.fileName ?? "";
    if (this.trackPosition) {
      if (msg.length > 0) {
        msg += ":";
      }
      msg += `${this.line}:${this.column}`;
    }
    if (msg.length > 0) {
      msg += ": ";
    }
    return new Error(msg + message);
  }

  /**
   * Report a parsing error. This method is made public so that client code may
   * check for issues that are outside the scope of this project and can report
   * errors.
   *
   * @param message The error to report.
   *
   * @returns this
   */
  fail(message: string): this {
    const err = this.makeError(message);
    const handler = this.errorHandler;
    if (handler === undefined) {
      throw err;
    }
    else {
      handler(err);
    }
    return this;
  }

  /**
   * Write a XML data to the parser.
   *
   * @param chunk The XML data to write.
   *
   * @returns this
   */
  write(chunk: string | {} | null): this {
    if (this.closed) {
      return this.fail("cannot write after close; assign an onready handler.");
    }

    let end = false;
    if (chunk === null) {
      // We cannot return immediately because carriedFromPrevious may need
      // processing.
      end = true;
      chunk = "";
    }
    else if (typeof chunk === "object") {
      chunk = chunk.toString();
    }

    // We checked if performing a pre-decomposition of the string into an array
    // of single complete characters (``Array.from(chunk)``) would be faster
    // than the current repeated calls to ``charCodeAt``. As of August 2018, it
    // isn't. (There may be Node-specific code that would perform faster than
    // ``Array.from`` but don't want to be dependent on Node.)

    if (this.carriedFromPrevious !== undefined) {
      // The previous chunk had char we must carry over.
      chunk = `${this.carriedFromPrevious}${chunk}`;
      this.carriedFromPrevious = undefined;
    }

    let limit = (chunk as string).length;
    const lastCode = (chunk as string).charCodeAt(limit - 1);
    if (!end &&
        // A trailing CR or surrogate must be carried over to the next
        // chunk.
        (lastCode === CR || (lastCode >= 0xD800 && lastCode <= 0xDBFF))) {
      // The chunk ends with a character that must be carried over. We cannot
      // know how to handle it until we get the next chunk or the end of the
      // stream. So save it for later.
      this.carriedFromPrevious = (chunk as string)[limit - 1];
      limit--;
      chunk = (chunk as string).slice(0, limit);
    }

    const { stateTable } = this;
    this.chunk = chunk as string;
    this.i = 0;
    while (this.i < limit) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      stateTable[this.state].call(this as any);
    }
    this.chunkPosition += limit;

    return end ? this.end() : this;
  }

  /**
   * Close the current stream. Perform final well-formedness checks and reset
   * the parser tstate.
   *
   * @returns this
   */
  close(): this {
    return this.write(null);
  }

  /**
   * Get a single code point out of the current chunk. This updates the current
   * position if we do position tracking.
   *
   * This is the algorithm to use for XML 1.0.
   *
   * @returns The character read.
   */
  private getCode10(): number {
    const { chunk, i } = this;
    this.prevI = i;
    // Yes, we do this instead of doing this.i++. Doing it this way, we do not
    // read this.i again, which is a bit faster.
    this.i = i + 1;

    if (i >= chunk.length) {
      return EOC;
    }

    // Using charCodeAt and handling the surrogates ourselves is faster
    // than using codePointAt.
    const code = chunk.charCodeAt(i);

    this.column++;
    if (code < 0xD800) {
      if (code >= SPACE || code === TAB) {
        return code;
      }

      switch (code) {
        case NL:
          this.line++;
          this.column = 0;
          this.positionAtNewLine = this.position;
          return NL;
        case CR:
          // We may get NaN if we read past the end of the chunk, which is fine.
          if (chunk.charCodeAt(i + 1) === NL) {
            // A \r\n sequence is converted to \n so we have to skip over the
            // next character. We already know it has a size of 1 so ++ is fine
            // here.
            this.i = i + 2;
          }
          // Otherwise, a \r is just converted to \n, so we don't have to skip
          // ahead.

          // In either case, \r becomes \n.
          this.line++;
          this.column = 0;
          this.positionAtNewLine = this.position;
          return NL_LIKE;
        default:
          // If we get here, then code < SPACE and it is not NL CR or TAB.
          this.fail("disallowed character.");
          return code;
      }
    }

    if (code > 0xDBFF) {
      // This is a specialized version of isChar10 that takes into account
      // that in this context code > 0xDBFF and code <= 0xFFFF. So it does not
      // test cases that don't need testing.
      if (!(code >= 0xE000 && code <= 0xFFFD)) {
        this.fail("disallowed character.");
      }

      return code;
    }

    const final = 0x10000 + ((code - 0xD800) * 0x400) +
      (chunk.charCodeAt(i + 1) - 0xDC00);
    this.i = i + 2;

    // This is a specialized version of isChar10 that takes into account that in
    // this context necessarily final >= 0x10000.
    if (final > 0x10FFFF) {
      this.fail("disallowed character.");
    }

    return final;
  }


  /**
   * Get a single code point out of the current chunk. This updates the current
   * position if we do position tracking.
   *
   * This is the algorithm to use for XML 1.1.
   *
   * @returns {number} The character read.
   */
  private getCode11(): number {
    const { chunk, i } = this;
    this.prevI = i;
    // Yes, we do this instead of doing this.i++. Doing it this way, we do not
    // read this.i again, which is a bit faster.
    this.i = i + 1;

    if (i >= chunk.length) {
      return EOC;
    }

    // Using charCodeAt and handling the surrogates ourselves is faster
    // than using codePointAt.
    const code = chunk.charCodeAt(i);

    this.column++;
    if (code < 0xD800) {
      if ((code > 0x1F && code < 0x7F) || (code > 0x9F && code !== LS) ||
          code === TAB) {
        return code;
      }

      switch (code) {
        case NL: // 0xA
          this.line++;
          this.column = 0;
          this.positionAtNewLine = this.position;
          return NL;
        case CR: { // 0xD
          // We may get NaN if we read past the end of the chunk, which is
          // fine.
          const next = chunk.charCodeAt(i + 1);
          if (next === NL || next === NEL) {
            // A CR NL or CR NEL sequence is converted to NL so we have to skip
            // over the next character. We already know it has a size of 1.
            this.i = i + 2;
          }
          // Otherwise, a CR is just converted to NL, no skip.
        }
        /* yes, fall through */
        case NEL: // 0x85
        case LS: // Ox2028
          this.line++;
          this.column = 0;
          this.positionAtNewLine = this.position;
          return NL_LIKE;
        default:
          this.fail("disallowed character.");
          return code;
      }
    }

    if (code > 0xDBFF) {
      // This is a specialized version of isCharAndNotRestricted that takes into
      // account that in this context code > 0xDBFF and code <= 0xFFFF. So it
      // does not test cases that don't need testing.
      if (!(code >= 0xE000 && code <= 0xFFFD)) {
        this.fail("disallowed character.");
      }

      return code;
    }

    const final = 0x10000 + ((code - 0xD800) * 0x400) +
      (chunk.charCodeAt(i + 1) - 0xDC00);
    this.i = i + 2;

    // This is a specialized version of isCharAndNotRestricted that takes into
    // account that in this context necessarily final >= 0x10000.
    if (final > 0x10FFFF) {
      this.fail("disallowed character.");
    }

    return final;
  }

  /**
   * Like ``getCode`` but with the return value normalized so that ``NL`` is
   * returned for ``NL_LIKE``.
   */
  private getCodeNorm(): number {
    const c = this.getCode();
    return c === NL_LIKE ? NL : c;
  }

  private unget(): void {
    this.i = this.prevI;
    this.column--;
  }

  /**
   * Capture characters into a buffer until encountering one of a set of
   * characters.
   *
   * @param chars An array of codepoints. Encountering a character in the array
   * ends the capture. (``chars`` may safely contain ``NL``.)
   *
   * @return The character code that made the capture end, or ``EOC`` if we hit
   * the end of the chunk. The return value cannot be NL_LIKE: NL is returned
   * instead.
   */
  private captureTo(chars: number[]): number {
    let { i: start } = this;
    const { chunk } = this;
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const c = this.getCode();
      const isNLLike = c === NL_LIKE;
      const final = isNLLike ? NL : c;
      if (final === EOC || chars.includes(final)) {
        this.text += chunk.slice(start, this.prevI);
        return final;
      }

      if (isNLLike) {
        this.text += `${chunk.slice(start, this.prevI)}\n`;
        start = this.i;
      }
    }
  }

  /**
   * Capture characters into a buffer until encountering a character.
   *
   * @param char The codepoint that ends the capture. **NOTE ``char`` MAY NOT
   * CONTAIN ``NL``.** Passing ``NL`` will result in buggy behavior.
   *
   * @return ``true`` if we ran into the character. Otherwise, we ran into the
   * end of the current chunk.
   */
  private captureToChar(char: number): boolean {
    let { i: start } = this;
    const { chunk } = this;
    // eslint-disable-next-line no-constant-condition
    while (true) {
      let c = this.getCode();
      switch (c) {
        case NL_LIKE:
          this.text += `${chunk.slice(start, this.prevI)}\n`;
          start = this.i;
          c = NL;
          break;
        case EOC:
          this.text += chunk.slice(start);
          return false;
        default:
      }

      if (c === char) {
        this.text += chunk.slice(start, this.prevI);
        return true;
      }
    }
  }

  /**
   * Capture characters that satisfy ``isNameChar`` into the ``name`` field of
   * this parser.
   *
   * @return The character code that made the test fail, or ``EOC`` if we hit
   * the end of the chunk. The return value cannot be NL_LIKE: NL is returned
   * instead.
   */
  private captureNameChars(): number {
    const { chunk, i: start } = this;
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const c = this.getCode();
      if (c === EOC) {
        this.name += chunk.slice(start);
        return EOC;
      }

      // NL is not a name char so we don't have to test specifically for it.
      if (!isNameChar(c)) {
        this.name += chunk.slice(start, this.prevI);
        return c === NL_LIKE ? NL : c;
      }
    }
  }

  /**
   * Skip white spaces.
   *
   * @return The character that ended the skip, or ``EOC`` if we hit
   * the end of the chunk. The return value cannot be NL_LIKE: NL is returned
   * instead.
   */
  private skipSpaces(): number {
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const c = this.getCodeNorm();
      if (c === EOC || !isS(c)) {
        return c;
      }
    }
  }

  private setXMLVersion(version: string): void {
    this.currentXMLVersion = version;
    /*  eslint-disable @typescript-eslint/unbound-method */
    if (version === "1.0") {
      this.isChar = isChar10;
      this.getCode = this.getCode10;
    }
    else {
      this.isChar = isChar11;
      this.getCode = this.getCode11;
    }
    /* eslint-enable @typescript-eslint/unbound-method */
  }

  // STATE ENGINE METHODS

  // This needs to be a state separate from S_BEGIN_WHITESPACE because we want
  // to be sure never to come back to this state later.
  private sBegin(): void {
    // We are essentially peeking at the first character of the chunk. Since
    // S_BEGIN can be in effect only when we start working on the first chunk,
    // the index at which we must look is necessarily 0. Note also that the
    // following test does not depend on decoding surrogates.

    // If the initial character is 0xFEFF, ignore it.
    if (this.chunk.charCodeAt(0) === 0xFEFF) {
      this.i++;
      this.column++;
    }

    this.state = S_BEGIN_WHITESPACE;
  }

  private sBeginWhitespace(): void {
    // We need to know whether we've encountered spaces or not because as soon
    // as we run into a space, an XML declaration is no longer possible. Rather
    // than slow down skipSpaces even in places where we don't care whether it
    // skipped anything or not, we check whether prevI is equal to the value of
    // i from before we skip spaces.
    const iBefore = this.i;
    const c = this.skipSpaces();
    if (this.prevI !== iBefore) {
      this.xmlDeclPossible = false;
    }

    switch (c) {
      case LESS:
        this.state = S_OPEN_WAKA;
        // We could naively call closeText but in this state, it is not normal
        // to have text be filled with any data.
        if (this.text.length !== 0) {
          throw new Error("no-empty text at start");
        }
        break;
      case EOC:
        break;
      default:
        this.unget();
        this.state = S_TEXT;
        this.xmlDeclPossible = false;
    }
  }

  private sDoctype(): void {
    const c = this.captureTo(DOCTYPE_TERMINATOR);
    switch (c) {
      case GREATER: {
        // eslint-disable-next-line no-unused-expressions
        this.doctypeHandler?.(this.text);
        this.text = "";
        this.state = S_TEXT;
        this.doctype = true; // just remember that we saw it.
        break;
      }
      case EOC:
        break;
      default:
        this.text += String.fromCodePoint(c);
        if (c === OPEN_BRACKET) {
          this.state = S_DTD;
        }
        else if (isQuote(c)) {
          this.state = S_DOCTYPE_QUOTE;
          this.q = c;
        }
    }
  }

  private sDoctypeQuote(): void {
    const q = this.q!;
    if (this.captureToChar(q)) {
      this.text += String.fromCodePoint(q);
      this.q = null;
      this.state = S_DOCTYPE;
    }
  }

  private sDTD(): void {
    const c = this.captureTo(DTD_TERMINATOR);
    if (c === EOC) {
      return;
    }

    this.text += String.fromCodePoint(c);
    if (c === CLOSE_BRACKET) {
      this.state = S_DOCTYPE;
    }
    else if (c === LESS) {
      this.state = S_DTD_OPEN_WAKA;
    }
    else if (isQuote(c)) {
      this.state = S_DTD_QUOTED;
      this.q = c;
    }
  }

  private sDTDQuoted(): void {
    const q = this.q!;
    if (this.captureToChar(q)) {
      this.text += String.fromCodePoint(q);
      this.state = S_DTD;
      this.q = null;
    }
  }

  private sDTDOpenWaka(): void {
    const c = this.getCodeNorm();
    this.text += String.fromCodePoint(c);
    switch (c) {
      case BANG:
        this.state = S_DTD_OPEN_WAKA_BANG;
        this.openWakaBang = "";
        break;
      case QUESTION:
        this.state = S_DTD_PI;
        break;
      default:
        this.state = S_DTD;
    }
  }

  private sDTDOpenWakaBang(): void {
    const char = String.fromCodePoint(this.getCodeNorm());
    const owb = this.openWakaBang += char;
    this.text += char;
    if (owb !== "-") {
      this.state = owb === "--" ? S_DTD_COMMENT : S_DTD;
      this.openWakaBang = "";
    }
  }

  private sDTDComment(): void {
    if (this.captureToChar(MINUS)) {
      this.text += "-";
      this.state = S_DTD_COMMENT_ENDING;
    }
  }

  private sDTDCommentEnding(): void {
    const c = this.getCodeNorm();
    this.text += String.fromCodePoint(c);
    this.state = c === MINUS ? S_DTD_COMMENT_ENDED : S_DTD_COMMENT;
  }

  private sDTDCommentEnded(): void {
    const c = this.getCodeNorm();
    this.text += String.fromCodePoint(c);
    if (c === GREATER) {
      this.state = S_DTD;
    }
    else {
      this.fail("malformed comment.");
      // <!-- blah -- bloo --> will be recorded as
      // a comment of " blah -- bloo "
      this.state = S_DTD_COMMENT;
    }
  }

  private sDTDPI(): void {
    if (this.captureToChar(QUESTION)) {
      this.text += "?";
      this.state = S_DTD_PI_ENDING;
    }
  }

  private sDTDPIEnding(): void {
    const c = this.getCodeNorm();
    this.text += String.fromCodePoint(c);
    if (c === GREATER) {
      this.state = S_DTD;
    }
  }

  private sText(): void {
    //
    // We did try a version of saxes where the S_TEXT state was split in two
    // states: one for text inside the root element, and one for text
    // outside. This was avoiding having to test this.tags.length to decide
    // what implementation to actually use.
    //
    // Peformance testing on gigabyte-size files did not show any advantage to
    // using the two states solution instead of the current one. Conversely, it
    // made the code a bit more complicated elsewhere. For instance, a comment
    // can appear before the root element so when a comment ended it was
    // necessary to determine whether to return to the S_TEXT state or to the
    // new text-outside-root state.
    //
    if (this.tags.length !== 0) {
      this.handleTextInRoot();
    }
    else {
      this.handleTextOutsideRoot();
    }
  }

  private sEntity(): void {
    // This is essentially a specialized version of captureToChar(SEMICOLON...)
    let { i: start } = this;
    const { chunk } = this;
    // eslint-disable-next-line no-labels, no-restricted-syntax
    loop:
    // eslint-disable-next-line no-constant-condition
    while (true) {
      switch (this.getCode()) {
        case NL_LIKE:
          this.entity += `${chunk.slice(start, this.prevI)}\n`;
          start = this.i;
          break;
        case SEMICOLON: {
          const { entityReturnState } = this;
          const entity = this.entity + chunk.slice(start, this.prevI);
          this.state = entityReturnState!;
          let parsed: string;
          if (entity === "") {
            this.fail("empty entity name.");
            parsed = "&;";
          }
          else {
            parsed = this.parseEntity(entity);
            this.entity = "";
          }

          if (entityReturnState !== S_TEXT || this.textHandler !== undefined) {
            this.text += parsed;
          }
          // eslint-disable-next-line no-labels
          break loop;
        }
        case EOC:
          this.entity += chunk.slice(start);
          // eslint-disable-next-line no-labels
          break loop;
        default:
      }
    }
  }

  private sOpenWaka(): void {
    // Reminder: a state handler is called with at least one character
    // available in the current chunk. So the first call to get code inside of
    // a state handler cannot return ``EOC``. That's why we don't test
    // for it.
    const c = this.getCode();
    // either a /, ?, !, or text is coming next.
    if (isNameStartChar(c)) {
      this.state = S_OPEN_TAG;
      this.unget();
      this.xmlDeclPossible = false;
    }
    else {
      switch (c) {
        case FORWARD_SLASH:
          this.state = S_CLOSE_TAG;
          this.xmlDeclPossible = false;
          break;
        case BANG:
          this.state = S_OPEN_WAKA_BANG;
          this.openWakaBang = "";
          this.xmlDeclPossible = false;
          break;
        case QUESTION:
          this.state = S_PI_FIRST_CHAR;
          break;
        default:
          this.fail("disallowed character in tag name");
          this.state = S_TEXT;
          this.xmlDeclPossible = false;
      }
    }
  }

  private sOpenWakaBang(): void {
    this.openWakaBang += String.fromCodePoint(this.getCodeNorm());
    switch (this.openWakaBang) {
      case "[CDATA[":
        if (!this.sawRoot && !this.reportedTextBeforeRoot) {
          this.fail("text data outside of root node.");
          this.reportedTextBeforeRoot = true;
        }

        if (this.closedRoot && !this.reportedTextAfterRoot) {
          this.fail("text data outside of root node.");
          this.reportedTextAfterRoot = true;
        }
        this.state = S_CDATA;
        this.openWakaBang = "";
        break;
      case "--":
        this.state = S_COMMENT;
        this.openWakaBang = "";
        break;
      case "DOCTYPE":
        this.state = S_DOCTYPE;
        if (this.doctype || this.sawRoot) {
          this.fail("inappropriately located doctype declaration.");
        }
        this.openWakaBang = "";
        break;
      default:
        // 7 happens to be the maximum length of the string that can possibly
        // match one of the cases above.
        if (this.openWakaBang.length >= 7) {
          this.fail("incorrect syntax.");
        }
    }
  }

  private sComment(): void {
    if (this.captureToChar(MINUS)) {
      this.state = S_COMMENT_ENDING;
    }
  }

  private sCommentEnding(): void {
    const c = this.getCodeNorm();
    if (c === MINUS) {
      this.state = S_COMMENT_ENDED;
      // eslint-disable-next-line no-unused-expressions
      this.commentHandler?.(this.text);
      this.text = "";
    }
    else {
      this.text += `-${String.fromCodePoint(c)}`;
      this.state = S_COMMENT;
    }
  }

  private sCommentEnded(): void {
    const c = this.getCodeNorm();
    if (c !== GREATER) {
      this.fail("malformed comment.");
      // <!-- blah -- bloo --> will be recorded as
      // a comment of " blah -- bloo "
      this.text += `--${String.fromCodePoint(c)}`;
      this.state = S_COMMENT;
    }
    else {
      this.state = S_TEXT;
    }
  }

  private sCData(): void {
    if (this.captureToChar(CLOSE_BRACKET)) {
      this.state = S_CDATA_ENDING;
    }
  }

  private sCDataEnding(): void {
    const c = this.getCodeNorm();
    if (c === CLOSE_BRACKET) {
      this.state = S_CDATA_ENDING_2;
    }
    else {
      this.text += `]${String.fromCodePoint(c)}`;
      this.state = S_CDATA;
    }
  }

  private sCDataEnding2(): void {
    const c = this.getCodeNorm();
    switch (c) {
      case GREATER: {
        // eslint-disable-next-line no-unused-expressions
        this.cdataHandler?.(this.text);
        this.text = "";
        this.state = S_TEXT;
        break;
      }
      case CLOSE_BRACKET:
        this.text += "]";
        break;
      default:
        this.text += `]]${String.fromCodePoint(c)}`;
        this.state = S_CDATA;
    }
  }

  // We need this separate state to check the first character fo the pi target
  // with this.nameStartCheck which allows less characters than this.nameCheck.
  private sPIFirstChar(): void {
    const c = this.getCodeNorm();
    // This is first because in the case where the file is well-formed this is
    // the branch taken. We optimize for well-formedness.
    if (this.nameStartCheck(c)) {
      this.piTarget += String.fromCodePoint(c);
      this.state = S_PI_REST;
    }
    else if (c === QUESTION || isS(c)) {
      this.fail("processing instruction without a target.");
      this.state = c === QUESTION ? S_PI_ENDING : S_PI_BODY;
    }
    else {
      this.fail("disallowed character in processing instruction name.");
      this.piTarget += String.fromCodePoint(c);
      this.state = S_PI_REST;
    }
  }

  private sPIRest(): void {
    // Capture characters into a piTarget while ``this.nameCheck`` run on the
    // character read returns true.
    const { chunk, i: start } = this;
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const c = this.getCodeNorm();
      if (c === EOC) {
        this.piTarget += chunk.slice(start);
        return;
      }

      // NL cannot satisfy this.nameCheck so we don't have to test specifically
      // for it.
      if (!this.nameCheck(c)) {
        this.piTarget += chunk.slice(start, this.prevI);
        const isQuestion = c === QUESTION;
        if (isQuestion || isS(c)) {
          if (this.piTarget === "xml") {
            if (!this.xmlDeclPossible) {
              this.fail(
                "an XML declaration must be at the start of the document.");
            }

            this.state = isQuestion ? S_XML_DECL_ENDING : S_XML_DECL_NAME_START;
          }
          else {
            this.state = isQuestion ? S_PI_ENDING : S_PI_BODY;
          }
        }
        else {
          this.fail("disallowed character in processing instruction name.");
          this.piTarget += String.fromCodePoint(c);
        }
        break;
      }
    }
  }

  private sPIBody(): void {
    if (this.text.length === 0) {
      const c = this.getCodeNorm();
      if (c === QUESTION) {
        this.state = S_PI_ENDING;
      }
      else if (!isS(c)) {
        this.text = String.fromCodePoint(c);
      }
    }
    // The question mark character is not valid inside any of the XML
    // declaration name/value pairs.
    else if (this.captureToChar(QUESTION)) {
      this.state = S_PI_ENDING;
    }
  }

  private sPIEnding(): void {
    const c = this.getCodeNorm();
    if (c === GREATER) {
      const { piTarget } = this;
      if (piTarget.toLowerCase() === "xml") {
        this.fail(
          "the XML declaration must appear at the start of the document.");
      }
      // eslint-disable-next-line no-unused-expressions
      this.piHandler?.({
        target: piTarget,
        body: this.text,
      });
      this.piTarget = this.text = "";
      this.state = S_TEXT;
    }
    else if (c === QUESTION) {
      // We ran into ?? as part of a processing instruction. We initially took
      // the first ? as a sign that the PI was ending, but it is not. So we have
      // to add it to the body but we take the new ? as a sign that the PI is
      // ending.
      this.text += "?";
    }
    else {
      this.text += `?${String.fromCodePoint(c)}`;
      this.state = S_PI_BODY;
    }
    this.xmlDeclPossible = false;
  }

  private sXMLDeclNameStart(): void {
    const c = this.skipSpaces();

    // The question mark character is not valid inside any of the XML
    // declaration name/value pairs.
    if (c === QUESTION) {
      // It is valid to go to S_XML_DECL_ENDING from this state.
      this.state = S_XML_DECL_ENDING;
      return;
    }

    if (c !== EOC) {
      this.state = S_XML_DECL_NAME;
      this.name = String.fromCodePoint(c);
    }
  }

  private sXMLDeclName(): void {
    const c = this.captureTo(XML_DECL_NAME_TERMINATOR);
    // The question mark character is not valid inside any of the XML
    // declaration name/value pairs.
    if (c === QUESTION) {
      this.state = S_XML_DECL_ENDING;
      this.name += this.text;
      this.text = "";
      this.fail("XML declaration is incomplete.");
      return;
    }

    if (!(isS(c) || c === EQUAL)) {
      return;
    }

    this.name += this.text;
    this.text = "";
    if (!this.xmlDeclExpects.includes(this.name)) {
      switch (this.name.length) {
        case 0:
          this.fail("did not expect any more name/value pairs.");
          break;
        case 1:
          this.fail(`expected the name ${this.xmlDeclExpects[0]}.`);
          break;
        default:
          this.fail(`expected one of ${this.xmlDeclExpects.join(", ")}`);
      }
    }

    this.state = c === EQUAL ? S_XML_DECL_VALUE_START : S_XML_DECL_EQ;
  }

  private sXMLDeclEq(): void {
    const c = this.getCodeNorm();
    // The question mark character is not valid inside any of the XML
    // declaration name/value pairs.
    if (c === QUESTION) {
      this.state = S_XML_DECL_ENDING;
      this.fail("XML declaration is incomplete.");
      return;
    }

    if (isS(c)) {
      return;
    }

    if (c !== EQUAL) {
      this.fail("value required.");
    }

    this.state = S_XML_DECL_VALUE_START;
  }

  private sXMLDeclValueStart(): void {
    const c = this.getCodeNorm();
    // The question mark character is not valid inside any of the XML
    // declaration name/value pairs.
    if (c === QUESTION) {
      this.state = S_XML_DECL_ENDING;
      this.fail("XML declaration is incomplete.");
      return;
    }

    if (isS(c)) {
      return;
    }

    if (!isQuote(c)) {
      this.fail("value must be quoted.");
      this.q = SPACE;
    }
    else {
      this.q = c;
    }

    this.state = S_XML_DECL_VALUE;
  }

  private sXMLDeclValue(): void {
    const c = this.captureTo([this.q!, QUESTION]);

    // The question mark character is not valid inside any of the XML
    // declaration name/value pairs.
    if (c === QUESTION) {
      this.state = S_XML_DECL_ENDING;
      this.text = "";
      this.fail("XML declaration is incomplete.");
      return;
    }

    if (c === EOC) {
      return;
    }

    const value = this.text;
    this.text = "";
    switch (this.name) {
      case "version": {
        this.xmlDeclExpects = ["encoding", "standalone"];
        const version = value;
        this.xmlDecl.version = version;
        // This is the test specified by XML 1.0 but it is fine for XML 1.1.
        if (!/^1\.[0-9]+$/.test(version)) {
          this.fail("version number must match /^1\\.[0-9]+$/.");
        }
        // When forceXMLVersion is set, the XML declaration is ignored.
        else if (!(this.opt.forceXMLVersion as boolean)) {
          this.setXMLVersion(version);
        }
        break;
      }
      case "encoding":
        if (!/^[A-Za-z][A-Za-z0-9._-]*$/.test(value)) {
          this.fail("encoding value must match \
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.");
        }
        this.xmlDeclExpects = ["standalone"];
        this.xmlDecl.encoding = value;
        break;
      case "standalone":
        if (value !== "yes" && value !== "no") {
          this.fail("standalone value must match \"yes\" or \"no\".");
        }
        this.xmlDeclExpects = [];
        this.xmlDecl.standalone = value;
        break;
      default:
        // We don't need to raise an error here since we've already raised one
        // when checking what name was expected.
    }
    this.name = "";
    this.state = S_XML_DECL_SEPARATOR;
  }

  private sXMLDeclSeparator(): void {
    const c = this.getCodeNorm();

    // The question mark character is not valid inside any of the XML
    // declaration name/value pairs.
    if (c === QUESTION) {
      // It is valid to go to S_XML_DECL_ENDING from this state.
      this.state = S_XML_DECL_ENDING;
      return;
    }

    if (!isS(c)) {
      this.fail("whitespace required.");
      this.unget();
    }

    this.state = S_XML_DECL_NAME_START;
  }

  private sXMLDeclEnding(): void {
    const c = this.getCodeNorm();
    if (c === GREATER) {
      if (this.piTarget !== "xml") {
        this.fail("processing instructions are not allowed before root.");
      }
      else if (this.name !== "version" &&
               this.xmlDeclExpects.includes("version")) {
        this.fail("XML declaration must contain a version.");
      }
      // eslint-disable-next-line no-unused-expressions
      this.xmldeclHandler?.(this.xmlDecl);
      this.name = "";
      this.piTarget = this.text = "";
      this.state = S_TEXT;
    }
    else {
      // We got here because the previous character was a ?, but the question
      // mark character is not valid inside any of the XML declaration
      // name/value pairs.
      this.fail(
        "The character ? is disallowed anywhere in XML declarations.");
    }
    this.xmlDeclPossible = false;
  }

  private sOpenTag(): void {
    const c = this.captureNameChars();
    if (c === EOC) {
      return;
    }

    const tag: SaxesTagIncomplete = this.tag = {
      name: this.name,
      attributes: Object.create(null) as Record<string, string>,
    };
    this.name = "";

    if (this.xmlnsOpt) {
      this.topNS = tag.ns = Object.create(null);
    }

    // eslint-disable-next-line no-unused-expressions
    this.openTagStartHandler?.(tag as StartTagForOptions<O>);
    this.sawRoot = true;
    if (!this.fragmentOpt && this.closedRoot) {
      this.fail("documents may contain only one root.");
    }

    switch (c) {
      case GREATER:
        this.openTag();
        break;
      case FORWARD_SLASH:
        this.state = S_OPEN_TAG_SLASH;
        break;
      default:
        if (!isS(c)) {
          this.fail("disallowed character in tag name.");
        }
        this.state = S_ATTRIB;
    }
  }

  private sOpenTagSlash(): void {
    if (this.getCode() === GREATER) {
      this.openSelfClosingTag();
    }
    else {
      this.fail("forward-slash in opening tag not followed by >.");
      this.state = S_ATTRIB;
    }
  }

  private sAttrib(): void {
    const c = this.skipSpaces();
    if (c === EOC) {
      return;
    }
    if (isNameStartChar(c)) {
      this.unget();
      this.state = S_ATTRIB_NAME;
    }
    else if (c === GREATER) {
      this.openTag();
    }
    else if (c === FORWARD_SLASH) {
      this.state = S_OPEN_TAG_SLASH;
    }
    else {
      this.fail("disallowed character in attribute name.");
    }
  }

  private sAttribName(): void {
    const c = this.captureNameChars();
    if (c === EQUAL) {
      this.state = S_ATTRIB_VALUE;
    }
    else if (isS(c)) {
      this.state = S_ATTRIB_NAME_SAW_WHITE;
    }
    else if (c === GREATER) {
      this.fail("attribute without value.");
      this.pushAttrib(this.name, this.name);
      this.name = this.text = "";
      this.openTag();
    }
    else if (c !== EOC) {
      this.fail("disallowed character in attribute name.");
    }
  }

  private sAttribNameSawWhite(): void {
    const c = this.skipSpaces();
    switch (c) {
      case EOC:
        return;
      case EQUAL:
        this.state = S_ATTRIB_VALUE;
        break;
      default:
        this.fail("attribute without value.");
        // Should we do this???
        // this.tag.attributes[this.name] = "";
        this.text = "";
        this.name = "";
        if (c === GREATER) {
          this.openTag();
        }
        else if (isNameStartChar(c)) {
          this.unget();
          this.state = S_ATTRIB_NAME;
        }
        else {
          this.fail("disallowed character in attribute name.");
          this.state = S_ATTRIB;
        }
    }
  }

  private sAttribValue(): void {
    const c = this.getCodeNorm();
    if (isQuote(c)) {
      this.q = c;
      this.state = S_ATTRIB_VALUE_QUOTED;
    }
    else if (!isS(c)) {
      this.fail("unquoted attribute value.");
      this.state = S_ATTRIB_VALUE_UNQUOTED;
      this.unget();
    }
  }

  private sAttribValueQuoted(): void {
    // We deliberately do not use captureTo here. The specialized code we use
    // here is faster than using captureTo.
    const { q, chunk } = this;
    let { i: start } = this;
    // eslint-disable-next-line no-constant-condition
    while (true) {
      switch (this.getCode()) {
        case q:
          this.pushAttrib(this.name,
                          this.text + chunk.slice(start, this.prevI));
          this.name = this.text = "";
          this.q = null;
          this.state = S_ATTRIB_VALUE_CLOSED;
          return;
        case AMP:
          this.text += chunk.slice(start, this.prevI);
          this.state = S_ENTITY;
          this.entityReturnState = S_ATTRIB_VALUE_QUOTED;
          return;
        case NL:
        case NL_LIKE:
        case TAB:
          this.text += `${chunk.slice(start, this.prevI)} `;
          start = this.i;
          break;
        case LESS:
          this.text += chunk.slice(start, this.prevI);
          this.fail("disallowed character.");
          return;
        case EOC:
          this.text += chunk.slice(start);
          return;
        default:
      }
    }
  }

  private sAttribValueClosed(): void {
    const c = this.getCodeNorm();
    if (isS(c)) {
      this.state = S_ATTRIB;
    }
    else if (c === GREATER) {
      this.openTag();
    }
    else if (c === FORWARD_SLASH) {
      this.state = S_OPEN_TAG_SLASH;
    }
    else if (isNameStartChar(c)) {
      this.fail("no whitespace between attributes.");
      this.unget();
      this.state = S_ATTRIB_NAME;
    }
    else {
      this.fail("disallowed character in attribute name.");
    }
  }

  private sAttribValueUnquoted(): void {
    // We don't do anything regarding EOL or space handling for unquoted
    // attributes. We already have failed by the time we get here, and the
    // contract that saxes upholds states that upon failure, it is not safe to
    // rely on the data passed to event handlers (other than
    // ``onerror``). Passing "bad" data is not a problem.
    const c = this.captureTo(ATTRIB_VALUE_UNQUOTED_TERMINATOR);
    switch (c) {
      case AMP:
        this.state = S_ENTITY;
        this.entityReturnState = S_ATTRIB_VALUE_UNQUOTED;
        break;
      case LESS:
        this.fail("disallowed character.");
        break;
      case EOC:
        break;
      default:
        if (this.text.includes("]]>")) {
          this.fail("the string \"]]>\" is disallowed in char data.");
        }
        this.pushAttrib(this.name, this.text);
        this.name = this.text = "";
        if (c === GREATER) {
          this.openTag();
        }
        else {
          this.state = S_ATTRIB;
        }
    }
  }

  private sCloseTag(): void {
    const c = this.captureNameChars();
    if (c === GREATER) {
      this.closeTag();
    }
    else if (isS(c)) {
      this.state = S_CLOSE_TAG_SAW_WHITE;
    }
    else if (c !== EOC) {
      this.fail("disallowed character in closing tag.");
    }
  }

  private sCloseTagSawWhite(): void {
    switch (this.skipSpaces()) {
      case GREATER:
        this.closeTag();
        break;
      case EOC:
        break;
      default:
        this.fail("disallowed character in closing tag.");
    }
  }

  // END OF STATE ENGINE METHODS

  private handleTextInRoot(): void {
    // This is essentially a specialized version of captureTo which is optimized
    // for performing the ]]> check. A previous version of this code, checked
    // ``this.text`` for the presence of ]]>. It simplified the code but was
    // very costly when character data contained a lot of entities to be parsed.
    //
    // Since we are using a specialized loop, we also keep track of the presence
    // of ]]> in text data. The sequence ]]> is forbidden to appear as-is.
    //
    let { i: start, forbiddenState } = this;
    const { chunk, textHandler: handler } = this;
    // eslint-disable-next-line no-labels, no-restricted-syntax
    scanLoop:
    // eslint-disable-next-line no-constant-condition
    while (true) {
      switch (this.getCode()) {
        case LESS: {
          this.state = S_OPEN_WAKA;
          if (handler !== undefined) {
            const { text } = this;
            const slice = chunk.slice(start, this.prevI);
            if (text.length !== 0) {
              handler(text + slice);
              this.text = "";
            }
            else if (slice.length !== 0) {
              handler(slice);
            }
          }
          forbiddenState = FORBIDDEN_START;
          // eslint-disable-next-line no-labels
          break scanLoop;
        }
        case AMP:
          this.state = S_ENTITY;
          this.entityReturnState = S_TEXT;
          if (handler !== undefined) {
            this.text += chunk.slice(start, this.prevI);
          }
          forbiddenState = FORBIDDEN_START;
          // eslint-disable-next-line no-labels
          break scanLoop;
        case CLOSE_BRACKET:
          switch (forbiddenState) {
            case FORBIDDEN_START:
              forbiddenState = FORBIDDEN_BRACKET;
              break;
            case FORBIDDEN_BRACKET:
              forbiddenState = FORBIDDEN_BRACKET_BRACKET;
              break;
            case FORBIDDEN_BRACKET_BRACKET:
              break;
            default:
              throw new Error("impossible state");
          }
          break;
        case GREATER:
          if (forbiddenState === FORBIDDEN_BRACKET_BRACKET) {
            this.fail("the string \"]]>\" is disallowed in char data.");
          }
          forbiddenState = FORBIDDEN_START;
          break;
        case NL_LIKE:
          if (handler !== undefined) {
            this.text += `${chunk.slice(start, this.prevI)}\n`;
          }
          start = this.i;
          forbiddenState = FORBIDDEN_START;
          break;
        case EOC:
          if (handler !== undefined) {
            this.text += chunk.slice(start);
          }
          // eslint-disable-next-line no-labels
          break scanLoop;
        default:
          forbiddenState = FORBIDDEN_START;
      }
    }
    this.forbiddenState = forbiddenState;
  }

  private handleTextOutsideRoot(): void {
    // This is essentially a specialized version of captureTo which is optimized
    // for a specialized task. We keep track of the presence of non-space
    // characters in the text since these are errors when appearing outside the
    // document root element.
    let { i: start } = this;
    const { chunk, textHandler: handler } = this;
    let nonSpace = false;
    // eslint-disable-next-line no-labels, no-restricted-syntax
    outRootLoop:
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const code = this.getCode();
      switch (code) {
        case LESS: {
          this.state = S_OPEN_WAKA;
          if (handler !== undefined) {
            const { text } = this;
            const slice = chunk.slice(start, this.prevI);
            if (text.length !== 0) {
              handler(text + slice);
              this.text = "";
            }
            else if (slice.length !== 0) {
              handler(slice);
            }
          }
          // eslint-disable-next-line no-labels
          break outRootLoop;
        }
        case AMP:
          this.state = S_ENTITY;
          this.entityReturnState = S_TEXT;
          if (handler !== undefined) {
            this.text += chunk.slice(start, this.prevI);
          }
          nonSpace = true;
          // eslint-disable-next-line no-labels
          break outRootLoop;
        case NL_LIKE:
          if (handler !== undefined) {
            this.text += `${chunk.slice(start, this.prevI)}\n`;
          }
          start = this.i;
          break;
        case EOC:
          if (handler !== undefined) {
            this.text += chunk.slice(start);
          }
          // eslint-disable-next-line no-labels
          break outRootLoop;
        default:
          if (!isS(code)) {
            nonSpace = true;
          }
      }
    }

    if (!nonSpace) {
      return;
    }

    // We use the reportedTextBeforeRoot and reportedTextAfterRoot flags
    // to avoid reporting errors for every single character that is out of
    // place.
    if (!this.sawRoot && !this.reportedTextBeforeRoot) {
      this.fail("text data outside of root node.");
      this.reportedTextBeforeRoot = true;
    }

    if (this.closedRoot && !this.reportedTextAfterRoot) {
      this.fail("text data outside of root node.");
      this.reportedTextAfterRoot = true;
    }
  }

  private pushAttribNS(name: string, value: string): void {
    const { prefix, local } = this.qname(name);
    const attr = { name, prefix, local, value };
    this.attribList.push(attr);
    // eslint-disable-next-line no-unused-expressions
    this.attributeHandler?.(attr as AttributeEventForOptions<O>);
    if (prefix === "xmlns") {
      const trimmed = value.trim();
      if (this.currentXMLVersion === "1.0" && trimmed === "") {
        this.fail("invalid attempt to undefine prefix in XML 1.0");
      }
      this.topNS![local] = trimmed;
      nsPairCheck(this, local, trimmed);
    }
    else if (name === "xmlns") {
      const trimmed = value.trim();
      this.topNS![""] = trimmed;
      nsPairCheck(this, "", trimmed);
    }
  }

  private pushAttribPlain(name: string, value: string): void {
    const attr = { name, value };
    this.attribList.push(attr);
    // eslint-disable-next-line no-unused-expressions
    this.attributeHandler?.(attr as AttributeEventForOptions<O>);
  }

  /**
   * End parsing. This performs final well-formedness checks and resets the
   * parser to a clean state.
   *
   * @returns this
   */
  private end(): this {
    if (!this.sawRoot) {
      this.fail("document must contain a root element.");
    }
    const { tags } = this;
    while (tags.length > 0) {
      const tag = tags.pop()!;
      this.fail(`unclosed tag: ${tag.name}`);
    }
    if ((this.state !== S_BEGIN) && (this.state !== S_TEXT)) {
      this.fail("unexpected end.");
    }
    const { text } = this;
    if (text.length !== 0) {
      // eslint-disable-next-line no-unused-expressions
      this.textHandler?.(text);
      this.text = "";
    }
    this._closed = true;
    // eslint-disable-next-line no-unused-expressions
    this.endHandler?.();
    this._init();
    return this;
  }

  /**
   * Resolve a namespace prefix.
   *
   * @param prefix The prefix to resolve.
   *
   * @returns The namespace URI or ``undefined`` if the prefix is not defined.
   */
  resolve(prefix: string): string | undefined {
    let uri = this.topNS![prefix];
    if (uri !== undefined) {
      return uri;
    }

    const { tags } = this;
    for (let index = tags.length - 1; index >= 0; index--) {
      uri = tags[index]!.ns![prefix];
      if (uri !== undefined) {
        return uri;
      }
    }

    uri = this.ns[prefix];
    if (uri !== undefined) {
      return uri;
    }

    return this.opt.resolvePrefix?.(prefix);
  }

  /**
   * Parse a qname into its prefix and local name parts.
   *
   * @param name The name to parse
   *
   * @returns
   */
  private qname(name: string): { prefix: string; local: string } {
    // This is faster than using name.split(":").
    const colon = name.indexOf(":");
    if (colon === -1) {
      return { prefix: "", local: name };
    }

    const local = name.slice(colon + 1);
    const prefix = name.slice(0, colon);
    if (prefix === "" || local === "" || local.includes(":")) {
      this.fail(`malformed name: ${name}.`);
    }

    return { prefix, local };
  }

  private processAttribsNS(): void {
    const { attribList } = this;
    const tag = this.tag!;

    {
      // add namespace info to tag
      const { prefix, local } = this.qname(tag.name);
      tag.prefix = prefix;
      tag.local = local;
      const uri = tag.uri = this.resolve(prefix) ?? "";

      if (prefix !== "") {
        if (prefix === "xmlns") {
          this.fail("tags may not have \"xmlns\" as prefix.");
        }

        if (uri === "") {
          this.fail(`unbound namespace prefix: ${JSON.stringify(prefix)}.`);
          tag.uri = prefix;
        }
      }
    }

    if (attribList.length === 0) {
      return;
    }

    const { attributes } = tag;
    const seen = new Set();
    // Note: do not apply default ns to attributes:
    //   http://www.w3.org/TR/REC-xml-names/#defaulting
    for (const attr of attribList as SaxesAttributeNSIncomplete[]) {
      const { name, prefix, local } = attr;
      let uri;
      let eqname;
      if (prefix === "") {
        uri = name === "xmlns" ? XMLNS_NAMESPACE : "";
        eqname = name;
      }
      else {
        uri = this.resolve(prefix);
        // if there's any attributes with an undefined namespace,
        // then fail on them now.
        if (uri === undefined) {
          this.fail(`unbound namespace prefix: ${JSON.stringify(prefix)}.`);
          uri = prefix;
        }
        eqname = `{${uri}}${local}`;
      }

      if (seen.has(eqname)) {
        this.fail(`duplicate attribute: ${eqname}.`);
      }
      seen.add(eqname);

      attr.uri = uri;
      attributes[name] = attr;
    }

    this.attribList = [];
  }

  private processAttribsPlain(): void {
    const { attribList } = this;
    // eslint-disable-next-line prefer-destructuring
    const attributes = this.tag!.attributes;
    for (const { name, value } of attribList) {
      if (attributes[name] !== undefined) {
        this.fail(`duplicate attribute: ${name}.`);
      }
      attributes[name] = value;
    }

    this.attribList = [];
  }

  /**
   * Handle a complete open tag. This parser code calls this once it has seen
   * the whole tag. This method checks for well-formeness and then emits
   * ``onopentag``.
   */
  private openTag(): void {
    this.processAttribs();

    const { tags } = this;
    const tag = this.tag as SaxesTag;
    tag.isSelfClosing = false;

    // There cannot be any pending text here due to the onopentagstart that was
    // necessarily emitted before we get here. So we do not check text.
    // eslint-disable-next-line no-unused-expressions
    this.openTagHandler?.(tag as TagForOptions<O>);
    tags.push(tag);
    this.state = S_TEXT;
    this.name = "";
  }

  /**
   * Handle a complete self-closing tag. This parser code calls this once it has
   * seen the whole tag. This method checks for well-formeness and then emits
   * ``onopentag`` and ``onclosetag``.
   */
  private openSelfClosingTag(): void {
    this.processAttribs();

    const { tags } = this;
    const tag = this.tag as SaxesTag;
    tag.isSelfClosing = true;

    // There cannot be any pending text here due to the onopentagstart that was
    // necessarily emitted before we get here. So we do not check text.
    // eslint-disable-next-line no-unused-expressions
    this.openTagHandler?.(tag as TagForOptions<O>);
    // eslint-disable-next-line no-unused-expressions
    this.closeTagHandler?.(tag as TagForOptions<O>);
    const top = this.tag = tags[tags.length - 1] ?? null;
    if (top === null) {
      this.closedRoot = true;
    }
    this.state = S_TEXT;
    this.name = "";
  }

  /**
   * Handle a complete close tag. This parser code calls this once it has seen
   * the whole tag. This method checks for well-formeness and then emits
   * ``onclosetag``.
   */
  private closeTag(): void {
    const { tags, name } = this;

    // Our state after this will be S_TEXT, no matter what, and we can clear
    // tagName now.
    this.state = S_TEXT;
    this.name = "";

    if (name === "") {
      this.fail("weird empty close tag.");
      this.text += "</>";
      return;
    }

    const handler = this.closeTagHandler;
    let l = tags.length;
    while (l-- > 0) {
      const tag = this.tag = tags.pop() as SaxesTag;
      this.topNS = tag.ns!;
      // eslint-disable-next-line no-unused-expressions
      handler?.(tag as TagForOptions<O>);
      if (tag.name === name) {
        break;
      }
      this.fail("unexpected close tag.");
    }

    if (l === 0) {
      this.closedRoot = true;
    }
    else if (l < 0) {
      this.fail(`unmatched closing tag: ${name}.`);
      this.text += `</${name}>`;
    }
  }

  /**
   * Resolves an entity. Makes any necessary well-formedness checks.
   *
   * @param entity The entity to resolve.
   *
   * @returns The parsed entity.
   */
  private parseEntity(entity: string): string {
    // startsWith would be significantly slower for this test.
    // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with
    if (entity[0] !== "#") {
      const defined = this.ENTITIES[entity];
      if (defined !== undefined) {
        return defined;
      }

      this.fail(this.isName(entity) ? "undefined entity." :
        "disallowed character in entity name.");
      return `&${entity};`;
    }

    let num = NaN;
    if (entity[1] === "x" && /^#x[0-9a-f]+$/i.test(entity)) {
      num = parseInt(entity.slice(2), 16);
    }
    else if (/^#[0-9]+$/.test(entity)) {
      num = parseInt(entity.slice(1), 10);
    }

    // The character reference is required to match the CHAR production.
    if (!this.isChar(num)) {
      this.fail("malformed character entity.");
      return `&${entity};`;
    }

    return String.fromCodePoint(num);
  }
}
