import {
    codePointToString,
    replaceCodePoint,
    replaceCodePointXML,
} from "./decode-codepoint.js";
import { htmlDecodeData } from "./generated/decode-data-html.js";
import {
    BUCKET_HASH_1,
    BUCKET_HASH_2,
    CHAR_REMAP,
    type DecodeData,
    initDecodeData,
    pairIndex,
} from "./internal/decode-data-format.js";

// Scalar constants avoid runtime enum objects with isolatedModules enabled.
const CHAR_AMP = 38; // "&"
const CHAR_NUM = 35; // "#"
const CHAR_SEMI = 59; // ";"
const CHAR_EQUALS = 61; // "="
const CHAR_ZERO = 48; // "0"
const CHAR_LOWER_A = 97; // "a"
const CHAR_LOWER_G = 103; // "g"
const CHAR_LOWER_L = 108; // "l"
const CHAR_LOWER_M = 109; // "m"
const CHAR_LOWER_O = 111; // "o"
const CHAR_LOWER_P = 112; // "p"
const CHAR_LOWER_Q = 113; // "q"
const CHAR_LOWER_S = 115; // "s"
const CHAR_LOWER_T = 116; // "t"
const CHAR_LOWER_U = 117; // "u"
const CHAR_LOWER_X = 120; // "x"
const CHAR_UPPER_A = 65; // "A"

/** Bit that needs to be set to convert an upper case ASCII character to lower case */
const TO_LOWER_BIT = 0b10_0000;

/**
 * Unsigned subtraction trick: (code - lo) >>> 0 wraps negatives to large
 * values, so a single `<=` covers the entire [lo..hi] range check.
 * @param code Code point to check.
 */
function isNumber(code: number): boolean {
    return (code - CHAR_ZERO) >>> 0 <= 9;
}

function isAlphaNumeric(code: number): boolean {
    return (
        isNumber(code) || ((code | TO_LOWER_BIT) - CHAR_LOWER_A) >>> 0 <= 25 // Z - a
    );
}

/**
 * Checks if the given character is a valid end character for an entity in an attribute.
 *
 * Attribute values that aren't terminated properly aren't parsed, and shouldn't lead to a parser error.
 * See the example in https://html.spec.whatwg.org/multipage/parsing.html#named-character-reference-state
 * @param code Code point to check.
 */
function isEntityInAttributeInvalidEnd(code: number): boolean {
    return code === CHAR_EQUALS || isAlphaNumeric(code);
}

/**
 * Decoding mode for named entities.
 */
export enum DecodingMode {
    /** Entities in text nodes that can end with any character. */
    Legacy = 0,
    /** Only allow entities terminated with a semicolon. */
    Strict = 1,
    /** Entities in attributes have limitations on ending characters. */
    Attribute = 2,
}

/** Decode data for HTML entities. */
const htmlDecode: DecodeData = /* #__PURE__ */ initDecodeData(htmlDecodeData);

/*
 * Every decoder below is specialized over these module-level constants. V8
 * compiles direct references to them roughly 20% faster than property loads
 * off a shared object.
 */
const htmlKeys = /* #__PURE__ */ ((): Int32Array => htmlDecode.keys)();
const htmlBuckets = /* #__PURE__ */ ((): number => htmlDecode.buckets)();
const htmlSlotMidOff = /* #__PURE__ */ ((): Uint16Array =>
    htmlDecode.slotMidOff)();
const htmlLengthBits = /* #__PURE__ */ ((): Uint32Array =>
    htmlDecode.lengthBits)();
const htmlMiddles = /* #__PURE__ */ ((): Uint32Array => htmlDecode.middles)();
/** Hoisted for the specialized HTML cores; see `emitHtmlValue`. */
const htmlSlotValue = /* #__PURE__ */ ((): Uint16Array =>
    htmlDecode.slotValue)();
const htmlValues = /* #__PURE__ */ ((): string => htmlDecode.values)();

/**
 * The replacement string for a packed `slotValue` entry. Most values need
 * just one UTF-16 code unit.
 * @param packed Replacement offset, legacy flag, and length from `htmlSlotValue`.
 */
function emitHtmlValue(packed: number): string {
    const off = packed >> 2;
    return (packed & 1) === 0
        ? htmlValues.charAt(off)
        : htmlValues.slice(off, off + (packed & 1) + 1);
}

/**
 * Find the slot matching `text[start..start+length)`, or -1. A match proves
 * every character of the span (exact key + middle comparison).
 * @param text Input text.
 * @param start Start of the span in `text`.
 * @param length Length of the span.
 */
function findSlotHtml(text: string, start: number, length: number): number {
    const c0 = text.charCodeAt(start);
    const c1 = text.charCodeAt(start + 1);
    const c2 = text.charCodeAt(start + length - 2);
    const c3 = text.charCodeAt(start + length - 1);
    /*
     * Probed spans aren't pre-filtered to alphanumerics; characters >= 0x80
     * would alias mod 128 inside the packed 7-bit fields, so reject them
     * before they can forge a key.
     */
    if ((c0 | c1 | c2 | c3) > 127) return -1;
    const key =
        (c0 << 25) | (c1 << 18) | (c2 << 11) | (CHAR_REMAP[c3] << 5) | length;
    let slot =
        2 * (((Math.imul(key, BUCKET_HASH_1) >>> 16) * htmlBuckets) >>> 16);
    for (let attempt = 0; ; attempt++) {
        if (
            htmlKeys[slot] === key &&
            (length <= 4 || isMidMatchHtml(slot, text, start, length))
        ) {
            return slot;
        }
        if (
            htmlKeys[slot + 1] === key &&
            (length <= 4 || isMidMatchHtml(slot + 1, text, start, length))
        ) {
            return slot + 1;
        }
        if (attempt === 1) return -1;
        slot =
            2 * (((Math.imul(key, BUCKET_HASH_2) >>> 16) * htmlBuckets) >>> 16);
    }
}

/**
 * Compare the middle characters (positions 2..length-3) of the candidate at
 * `slot` against the input. The key already proves the outer characters and
 * the length. Names of length at most four are accepted by the caller.
 * @param slot Slot of the candidate.
 * @param text Input text.
 * @param start Start of the span in `text`.
 * @param length Length of the span.
 */
function isMidMatchHtml(
    slot: number,
    text: string,
    start: number,
    length: number,
): boolean {
    let wordIndex = htmlSlotMidOff[slot];
    let index = start + 2;
    const end = start + length - 2;
    while (index + 1 < end) {
        if (
            (text.charCodeAt(index) | (text.charCodeAt(index + 1) << 16)) !==
            htmlMiddles[wordIndex++]
        )
            return false;
        index += 2;
    }
    return (
        index === end ||
        text.charCodeAt(index) === (htmlMiddles[wordIndex] & 0xff_ff)
    );
}

/**
 * `parseNumericEntity` packs its two results into one integer:
 * `(consumed << CONSUMED_SHIFT) | codePoint`. The code point occupies the low
 * 21 bits (max 0x110000, clamped before packing); `consumed` takes the upper
 * 11 bits and is read back with `>>> CONSUMED_SHIFT`, so the sign bit set by a
 * large `consumed` is harmless. The maximum consumed field is reserved for
 * long references, whose full length is stored in `longNumericConsumed`.
 * A zero return means "no numeric entity".
 */
const NUMERIC_CONSUMED_SHIFT = 21;
const NUMERIC_CODE_POINT_MASK = 0x1f_ff_ff;
const NUMERIC_CONSUMED_OVERFLOW = 0x7_ff;

/** Full length, including `&`, when the packed consumed field overflows. */
let longNumericConsumed = 0;

/**
 * Recover the consumed count before the next numeric parse can overwrite
 * `longNumericConsumed`. This avoids allocating a tuple for each reference.
 * @param packed Packed result of `parseNumericEntity`.
 */
function unpackConsumed(packed: number): number {
    const consumed = packed >>> NUMERIC_CONSUMED_SHIFT;
    return consumed === NUMERIC_CONSUMED_OVERFLOW
        ? longNumericConsumed
        : consumed;
}

/** ASCII digit values; 0xff marks characters outside the hexadecimal range. */
const numericDigits: Uint8Array = /* #__PURE__ */ ((): Uint8Array => {
    const digits = new Uint8Array(128).fill(0xff);
    for (let digit = 0; digit < 10; digit++) {
        digits[CHAR_ZERO + digit] = digit;
    }
    for (let digit = 0; digit < 6; digit++) {
        digits[CHAR_UPPER_A + digit] = digit + 10;
        digits[CHAR_LOWER_A + digit] = digit + 10;
    }
    return digits;
})();

/**
 * Parse a numeric entity starting right after the `#`. In legacy mode the
 * terminating semicolon is optional. Returns the number of characters
 * consumed (counting the `&` and `#`) packed with the code point (see
 * the packing constants above), or 0 if there is no valid numeric entity at this
 * position.
 * @param input Input string.
 * @param offset Index right after the `#`.
 * @param isStrict Whether a terminating semicolon is required.
 */
function parseNumericEntity(
    input: string,
    offset: number,
    isStrict: boolean,
): number {
    const inputLength = input.length;
    let index = offset;
    let codePoint = 0;
    let digitsStart: number;
    if ((input.charCodeAt(index) | TO_LOWER_BIT) === CHAR_LOWER_X) {
        // Hexadecimal entity.
        index += 1;
        digitsStart = index;
        while (index < inputLength) {
            const char = input.charCodeAt(index);
            if (char >= 128) break;
            const digit = numericDigits[char];
            if (digit <= 15) {
                codePoint = codePoint * 16 + digit;
                index++;
            } else {
                break;
            }
        }
    } else {
        digitsStart = index;
        while (index < inputLength) {
            const char = input.charCodeAt(index);
            if (char >= 128) break;
            const digit = numericDigits[char];
            if (digit <= 9) {
                codePoint = codePoint * 10 + digit;
                index++;
            } else {
                break;
            }
        }
    }
    if (index === digitsStart) return 0;
    // Clamp once after the loop instead of per digit.
    if (codePoint > 0x10_ff_ff) codePoint = 0x11_00_00;
    let consumed = index - offset + 2; // Includes "#" and the "&" position.
    if (index < inputLength && input.charCodeAt(index) === CHAR_SEMI) {
        consumed += 1;
    } else if (isStrict) {
        return 0;
    }
    if (consumed >= NUMERIC_CONSUMED_OVERFLOW) {
        // eslint-disable-next-line unicorn/no-top-level-assignment-in-function -- deliberate side channel, read immediately by unpackConsumed
        longNumericConsumed = consumed;
        consumed = NUMERIC_CONSUMED_OVERFLOW;
    }
    return (consumed << NUMERIC_CONSUMED_SHIFT) | codePoint;
}

/**
 * Find the longest legacy (semicolon-less) match for the name starting at
 * `start`, using the per-class legacy length bits. Returns
 * `slot << 3 | matchLength`, or -1 if there is no match. Only called after
 * the exact probes failed (the miss path).
 * @param input Input text.
 * @param start Start of the name.
 * @param maxLength Number of available run characters.
 */
function findLegacySlot(
    input: string,
    start: number,
    maxLength: number,
): number {
    let legacy =
        (htmlLengthBits[
            pairIndex(input.charCodeAt(start), input.charCodeAt(start + 1))
        ] >>>
            16) &
        31;
    while (legacy !== 0) {
        // Longest first: the spec matches references greedily.
        const top = 31 - Math.clz32(legacy);
        legacy ^= 1 << top;
        if (top + 2 > maxLength) continue;
        const slot = findSlotHtml(input, start, top + 2);
        if (slot >= 0 && (htmlSlotValue[slot] & 2) !== 0) {
            return (slot << 3) | (top + 2);
        }
    }
    return -1;
}

/**
 * Match a class containing long names without probing all its shorter lengths.
 * The 32-character window bounds the scan even for invalid or unterminated runs.
 * Returns `(slot << 6) | consumed`, excluding the `&`, or -1 on a miss.
 * @param input Input containing the candidate name.
 * @param start Start of the name.
 * @param mode Decoding mode, including the rules for legacy matches.
 */
function findLongClassMatch(
    input: string,
    start: number,
    mode: DecodingMode,
): number {
    const length = input.slice(start, start + 32).indexOf(";");
    if ((length - 2) >>> 0 <= 29) {
        const slot = findSlotHtml(input, start, length);
        if (slot >= 0) return (slot << 6) | (length + 1);
    }
    if (mode !== DecodingMode.Strict) {
        const packed = findLegacySlot(
            input,
            start,
            Math.min(31, input.length - start),
        );
        if (packed >= 0) {
            const length = packed & 7;
            if (
                mode !== DecodingMode.Attribute ||
                start + length >= input.length ||
                !isEntityInAttributeInvalidEnd(input.charCodeAt(start + length))
            )
                return ((packed >> 3) << 6) | length;
        }
    }
    return -1;
}

/**
 * Match one of XML's five predefined entities (name plus the terminating
 * semicolon) at `start`. Returns `(consumedLength << 7) | codePoint` where
 * `consumedLength` counts the name and the semicolon, or -1 if nothing
 * matches. All five patterns are decided within five characters of `start`,
 * so the entity set ships no decode data.
 * @param input Input text.
 * @param start Index of the name's first character (right after the `&`).
 */
function matchXmlEntity(input: string, start: number): number {
    /*
     * Direct char-code compares: `startsWith` costs a builtin call per
     * probe, measured ~10% of entity-dense XML decode. Loads stay inside
     * the cases so the miss path (`default`) pays nothing.
     */
    switch (input.charCodeAt(start)) {
        case CHAR_LOWER_L: {
            return input.charCodeAt(start + 1) === CHAR_LOWER_T &&
                input.charCodeAt(start + 2) === CHAR_SEMI
                ? (3 << 7) | 0x3c
                : -1;
        }
        case CHAR_LOWER_G: {
            return input.charCodeAt(start + 1) === CHAR_LOWER_T &&
                input.charCodeAt(start + 2) === CHAR_SEMI
                ? (3 << 7) | 0x3e
                : -1;
        }
        case CHAR_LOWER_A: {
            const c1 = input.charCodeAt(start + 1);
            const c2 = input.charCodeAt(start + 2);
            if (
                c1 === CHAR_LOWER_M &&
                c2 === CHAR_LOWER_P &&
                input.charCodeAt(start + 3) === CHAR_SEMI
            ) {
                return (4 << 7) | 0x26;
            }
            return c1 === CHAR_LOWER_P &&
                c2 === CHAR_LOWER_O &&
                input.charCodeAt(start + 3) === CHAR_LOWER_S &&
                input.charCodeAt(start + 4) === CHAR_SEMI
                ? (5 << 7) | 0x27
                : -1;
        }
        case CHAR_LOWER_Q: {
            return input.charCodeAt(start + 1) === CHAR_LOWER_U &&
                input.charCodeAt(start + 2) === CHAR_LOWER_O &&
                input.charCodeAt(start + 3) === CHAR_LOWER_T &&
                input.charCodeAt(start + 4) === CHAR_SEMI
                ? (5 << 7) | 0x22
                : -1;
        }
        default: {
            return -1;
        }
    }
}

/**
 * The next `&` to resume from after emitting a replacement, given the index
 * just past it. A leaf the decoders call after every emit: the common
 * adjacent-entity case (`&amp;&lt;`) skips the `indexOf` C++ call.
 * @param input Input text.
 * @param last Index just past the entity that was emitted.
 */
function nextOffset(input: string, last: number): number {
    if (last >= input.length) return -1;
    return input.charCodeAt(last) === CHAR_AMP
        ? last
        : input.indexOf("&", last);
}

/**
 * Synchronous HTML decoder, shared by all three decoding modes.
 * @param input String to decode.
 * @param mode Decoding mode for named entities.
 */
function decodeHtmlText(input: string, mode: DecodingMode): string {
    const isLegacyAllowed = mode !== DecodingMode.Strict;
    let offset = input.indexOf("&");
    if (offset < 0) return input;
    const inputLength = input.length;
    let result = "";
    let last = 0;
    do {
        const start = offset + 1;
        if (start + 1 >= inputLength) break;
        const c0 = input.charCodeAt(start);
        if (c0 === CHAR_AMP) {
            // Adjacent "&&": re-enter directly, skipping indexOf.
            offset = start;
            continue;
        }
        if (c0 === CHAR_NUM) {
            const packed = parseNumericEntity(
                input,
                start + 1,
                !isLegacyAllowed,
            );
            const consumed = unpackConsumed(packed);
            if (consumed === 0) {
                offset = input.indexOf("&", start);
            } else {
                if (last !== offset) {
                    result += input.slice(last, offset);
                }
                result += codePointToString(packed & NUMERIC_CODE_POINT_MASK);
                last = offset + consumed;
                offset = nextOffset(input, last);
            }
            continue;
        }
        /*
         * Named entity. The (c0,c1) class lists every length a matching
         * name can have; probe `;` at each. A probe hit is fully
         * verified by `findSlot`; no scanning is needed. A `;` miss at
         * a legacy-marked length falls through to a direct legacy
         * lookup: legacy names need no terminator.
         */
        const bits = htmlLengthBits[pairIndex(c0, input.charCodeAt(start + 1))];
        if ((bits & 0x80_00) !== 0) {
            const packed = findLongClassMatch(input, start, mode);
            if (packed >= 0) {
                if (last !== offset) result += input.slice(last, offset);
                result += emitHtmlValue(htmlSlotValue[packed >> 6]);
                last = start + (packed & 63);
                offset = nextOffset(input, last);
            } else {
                offset = input.indexOf("&", start);
            }
            continue;
        }
        let probed = bits & 0x7f_ff;
        /*
         * Slot in bits 6+, semicolon in bit 5, consumed name span in bits 0-4.
         * This path only handles names up to 16 characters, plus their ';'.
         */
        let matched = -1;
        while (probed !== 0) {
            const low = probed & -probed;
            probed ^= low;
            const length = 33 - Math.clz32(low);
            const end = start + length;
            // Longer probes cannot match either; lengths are ascending.
            // eslint-disable-next-line unicorn/no-break-in-nested-loop -- keep the bounded probe loop inline
            if (end > inputLength) break;
            const isTerminated =
                end < inputLength && input.charCodeAt(end) === CHAR_SEMI;
            if (
                !(
                    isTerminated ||
                    (isLegacyAllowed && ((bits >>> (length + 14)) & 1) !== 0)
                )
            )
                // eslint-disable-next-line unicorn/no-break-in-nested-loop -- skip impossible candidates in the inline probe loop
                continue;
            // Share the lookup call site, including after legacy-only inputs.
            const slot = findSlotHtml(input, start, length);
            // eslint-disable-next-line unicorn/no-break-in-nested-loop -- skip failed candidates in the inline probe loop
            if (slot < 0) continue;
            if (isTerminated) {
                matched = (slot << 6) | 32 | (length + 1);
                // eslint-disable-next-line unicorn/no-break-in-nested-loop -- an exact match ends the probe loop
                break;
            }
            if ((htmlSlotValue[slot] & 2) !== 0) {
                matched = (slot << 6) | length;
            }
        }
        if (matched >= 0) {
            const length = matched & 31;
            if (
                (matched & 32) !== 0 ||
                mode !== DecodingMode.Attribute ||
                start + length >= inputLength ||
                !isEntityInAttributeInvalidEnd(input.charCodeAt(start + length))
            ) {
                if (last !== offset) result += input.slice(last, offset);
                result += emitHtmlValue(htmlSlotValue[matched >> 6]);
                last = start + length;
                offset = nextOffset(input, last);
                continue;
            }
        }
        offset = input.indexOf("&", start + 1);
    } while (offset >= 0);
    return result + input.slice(last);
}

/**
 * Decodes an HTML string.
 * @param htmlString The string to decode.
 * @param mode The decoding mode.
 * @returns The decoded string.
 */
export function decodeHTML(
    htmlString: string,
    mode: DecodingMode = DecodingMode.Legacy,
): string {
    return decodeHtmlText(htmlString, mode);
}

/**
 * Decodes an HTML string in an attribute.
 * @param htmlAttribute The string to decode.
 * @returns The decoded string.
 */
export function decodeHTMLAttribute(htmlAttribute: string): string {
    return decodeHtmlText(htmlAttribute, DecodingMode.Attribute);
}

/**
 * Decodes an HTML string, requiring all entities to be terminated by a
 * semicolon.
 * @param htmlString The string to decode.
 * @returns The decoded string.
 */
export function decodeHTMLStrict(htmlString: string): string {
    return decodeHtmlText(htmlString, DecodingMode.Strict);
}

/**
 * Decodes an XML string, requiring all entities to be terminated by a
 * semicolon.
 * @param xmlString The string to decode.
 * @returns The decoded string.
 */
export function decodeXML(xmlString: string): string {
    let offset = xmlString.indexOf("&");
    if (offset < 0) return xmlString;
    let lastIndex = 0;
    let result = "";
    do {
        const start = offset + 1;
        if (start + 1 >= xmlString.length) break;
        let consumed = 0;
        let value = "";
        const c1 = xmlString.charCodeAt(start);
        if (c1 === CHAR_NUM) {
            const packed = parseNumericEntity(xmlString, start + 1, true);
            consumed = unpackConsumed(packed);
            if (consumed !== 0) {
                const codePoint = packed & NUMERIC_CODE_POINT_MASK;
                value =
                    (codePoint - 1) >>> 0 < 0xd7_ff
                        ? String.fromCharCode(codePoint)
                        : String.fromCodePoint(replaceCodePointXML(codePoint));
            }
        } else {
            /* eslint-disable unicorn/no-break-in-nested-loop -- Keep XML name dispatch inline with the decode loop. */
            switch (c1) {
                // &lt; / &gt;
                case 0x6c:
                case 0x67: {
                    if (
                        start + 2 < xmlString.length &&
                        xmlString.charCodeAt(start + 1) === 0x74 &&
                        xmlString.charCodeAt(start + 2) === CHAR_SEMI
                    ) {
                        consumed = 4;
                        value = c1 === 0x6c ? "<" : ">";
                    }
                    break;
                }
                // &amp; / &apos;
                case 0x61: {
                    const c2 = xmlString.charCodeAt(start + 1);
                    if (
                        start + 3 < xmlString.length &&
                        c2 === 0x6d &&
                        xmlString.charCodeAt(start + 2) === 0x70 &&
                        xmlString.charCodeAt(start + 3) === CHAR_SEMI
                    ) {
                        consumed = 5;
                        value = "&";
                    } else if (
                        start + 4 < xmlString.length &&
                        c2 === 0x70 &&
                        xmlString.charCodeAt(start + 2) === 0x6f &&
                        xmlString.charCodeAt(start + 3) === 0x73 &&
                        xmlString.charCodeAt(start + 4) === CHAR_SEMI
                    ) {
                        consumed = 6;
                        value = "'";
                    }
                    break;
                }
                // &quot;
                case 0x71: {
                    if (
                        start + 4 < xmlString.length &&
                        xmlString.charCodeAt(start + 1) === 0x75 &&
                        xmlString.charCodeAt(start + 2) === 0x6f &&
                        xmlString.charCodeAt(start + 3) === 0x74 &&
                        xmlString.charCodeAt(start + 4) === CHAR_SEMI
                    ) {
                        consumed = 6;
                        value = '"';
                    }
                    break;
                }
            }
            /* eslint-enable unicorn/no-break-in-nested-loop */
        }
        if (consumed > 0) {
            if (lastIndex < offset)
                result += xmlString.slice(lastIndex, offset);
            result += value;
            offset = lastIndex = offset + consumed;
        } else {
            offset = start;
        }
        /*
         * Adjacent entities (`&x;&y;`) are common in entity-dense input;
         * checking the single character at `lastIndex` first skips the
         * `indexOf` call (and its per-call overhead) for that case.
         */
        offset =
            offset < xmlString.length &&
            xmlString.charCodeAt(offset) === CHAR_AMP
                ? offset
                : xmlString.indexOf("&", offset);
    } while (offset >= 0);
    return result + xmlString.slice(lastIndex);
}

const STATE_ENTITY_START = 0;
const STATE_NUMERIC_START = 1;
const STATE_NUMERIC_DECIMAL = 2;
const STATE_NUMERIC_HEX = 3;
const STATE_NAMED_ENTITY = 4;

/**
 * Producers for character reference errors as defined in the HTML spec.
 */
export interface EntityErrorProducer {
    missingSemicolonAfterCharacterReference(): void;
    absenceOfDigitsInNumericCharacterReference(
        consumedCharacters: number,
    ): void;
    /**
     * Validate the accumulated numeric value, before Unicode replacement.
     * Values beyond the JavaScript number range are positive infinity.
     */
    validateNumericCharacterReference(code: number): void;
}

/**
 * Token decoder with support of writing partial entities; the shared base
 * of `HtmlEntityDecoder` and `XmlEntityDecoder`. Numeric entities are
 * identical in both document types and are handled here; named entities are
 * left to the subclasses.
 *
 * The decoder is driven by a tokenizer: after a `&`, write the following
 * input (possibly in chunks). `write` returns the number of characters
 * consumed once the entity is decided, or -1 while more input is needed.
 * Rejection may surface later than strictly possible (the decoder buffers
 * until a terminator or the name-length limit), but emitted code points,
 * consumed counts, and final return values are exact.
 */
abstract class EntityDecoderBase {
    /** The current state of the decoder. */
    protected state: number = STATE_ENTITY_START;
    /** Characters that were consumed while parsing an entity. */
    protected consumed = 1;
    /** Accumulated numeric code point, or a packed partial XML name. */
    protected result = 0;
    /** The mode in which the decoder is operating. */
    protected decodeMode: DecodingMode = DecodingMode.Strict;

    /** Replacement rules for numeric character references. */
    protected readonly replaceNumericCodePoint: (codePoint: number) => number =
        replaceCodePoint;

    constructor(
        /**
         * The function that is called when a codepoint is decoded.
         *
         * For multi-byte named entities, this will be called multiple times,
         * with the second codepoint, and the same `consumed` value.
         * @param codepoint The decoded codepoint.
         * @param consumed The number of bytes consumed by the decoder.
         */
        protected readonly emitCodePoint: (
            cp: number,
            consumed: number,
        ) => void,
        /** An object that is used to produce errors. */
        protected readonly errors?: EntityErrorProducer | undefined,
    ) {}

    /**
     * Switches between the numeric decimal and hexadecimal states.
     *
     * Equivalent to the `Numeric character reference state` in the HTML spec.
     * @param input The string containing the entity (or a continuation of the entity).
     * @param offset The current offset.
     * @returns The number of characters that were consumed, or -1 if the entity is incomplete.
     */
    private stateNumericStart(input: string, offset: number): number {
        if (offset >= input.length) {
            return -1;
        }

        if ((input.charCodeAt(offset) | TO_LOWER_BIT) === CHAR_LOWER_X) {
            this.state = STATE_NUMERIC_HEX;
            this.consumed += 1;
            return this.stateNumericHex(input, offset + 1);
        }

        this.state = STATE_NUMERIC_DECIMAL;
        return this.stateNumericDecimal(input, offset);
    }

    /**
     * Parses a hexadecimal numeric entity.
     *
     * Equivalent to the `Hexadecimal character reference state` in the HTML spec.
     * @param input The string containing the entity (or a continuation of the entity).
     * @param offset The current offset.
     * @returns The number of characters that were consumed, or -1 if the entity is incomplete.
     */
    private stateNumericHex(input: string, offset: number): number {
        const inputLength = input.length;
        // Local accumulators; flushed before any exit.
        let { result, consumed } = this;
        let index = offset;
        while (index < inputLength) {
            const char = input.charCodeAt(index);
            const digit = numericDigits[char];
            if (digit <= 15) {
                result = result * 16 + digit;
            } else {
                this.result = result;
                this.consumed = consumed;
                return this.emitNumericEntity(char, 3);
            }
            consumed += 1;
            index += 1;
        }
        this.result = result;
        this.consumed = consumed;
        return -1; // Incomplete entity
    }

    /**
     * Parses a decimal numeric entity.
     *
     * Equivalent to the `Decimal character reference state` in the HTML spec.
     * @param input The string containing the entity (or a continuation of the entity).
     * @param offset The current offset.
     * @returns The number of characters that were consumed, or -1 if the entity is incomplete.
     */
    private stateNumericDecimal(input: string, offset: number): number {
        const inputLength = input.length;
        // Local accumulators; flushed before any exit.
        let { result, consumed } = this;
        let index = offset;
        while (index < inputLength) {
            const digit = input.charCodeAt(index) - CHAR_ZERO;
            if (digit >>> 0 > 9) {
                this.result = result;
                this.consumed = consumed;
                return this.emitNumericEntity(digit + CHAR_ZERO, 2);
            }
            result = result * 10 + digit;
            consumed += 1;
            index += 1;
        }
        this.result = result;
        this.consumed = consumed;
        return -1; // Incomplete entity
    }

    /**
     * Validate and emit a numeric entity.
     *
     * Implements the logic from the `Hexadecimal character reference start
     * state` and `Numeric character reference end state` in the HTML spec.
     * @param lastCp The last code point of the entity. Used to see if the
     *               entity was terminated with a semicolon.
     * @param expectedLength The minimum number of characters that should be
     *                       consumed. Used to validate that at least one digit
     *                       was consumed.
     * @returns The number of characters that were consumed.
     */
    private emitNumericEntity(lastCp: number, expectedLength: number): number {
        // Ensure we consumed at least one digit.
        if (this.consumed <= expectedLength) {
            this.errors?.absenceOfDigitsInNumericCharacterReference(
                this.consumed,
            );
            return 0;
        }

        // Figure out if this is a legit end of the entity
        if (lastCp === CHAR_SEMI) {
            this.consumed += 1;
        } else if (this.decodeMode === DecodingMode.Strict) {
            return 0;
        }

        this.emitCodePoint(
            this.replaceNumericCodePoint(this.result),
            this.consumed,
        );

        if (this.errors) {
            if (lastCp !== CHAR_SEMI) {
                this.errors.missingSemicolonAfterCharacterReference();
            }
            this.errors.validateNumericCharacterReference(this.result);
        }

        return this.consumed;
    }

    /**
     * Resets the instance to make it reusable.
     * @param decodeMode Entity decoding mode to use.
     */
    startEntity(decodeMode: DecodingMode): void {
        this.decodeMode = decodeMode;
        this.state = STATE_ENTITY_START;
        this.result = 0;
        this.consumed = 1;
    }

    /**
     * Write an entity to the decoder. This can be called multiple times with partial entities.
     * If the entity is incomplete, the decoder will return -1.
     * @param input The string containing the entity (or a continuation of the entity).
     * @param offset The offset at which the entity begins. Should be 0 if this is not the first call.
     * @returns The number of characters that were consumed, or -1 if the entity is incomplete.
     */
    write(input: string, offset: number): number {
        switch (this.state) {
            case STATE_ENTITY_START: {
                if (input.charCodeAt(offset) === CHAR_NUM) {
                    this.state = STATE_NUMERIC_START;
                    this.consumed += 1;
                    return this.stateNumericStart(input, offset + 1);
                }
                this.state = STATE_NAMED_ENTITY;
                return this.stateNamedEntity(input, offset);
            }

            case STATE_NUMERIC_START: {
                return this.stateNumericStart(input, offset);
            }

            case STATE_NUMERIC_DECIMAL: {
                return this.stateNumericDecimal(input, offset);
            }

            case STATE_NUMERIC_HEX: {
                return this.stateNumericHex(input, offset);
            }

            default: {
                // NamedEntity: the only remaining state.
                return this.stateNamedEntity(input, offset);
            }
        }
    }

    /**
     * Decode a named entity.
     * @param input The string containing the entity (or a continuation of the entity).
     * @param offset The current offset.
     * @returns The number of characters that were consumed, or -1 if the entity is incomplete.
     */
    protected abstract stateNamedEntity(input: string, offset: number): number;

    /**
     * Resolve a named entity that was still incomplete when the input
     * ended.
     * @returns The number of characters that were consumed.
     */
    protected abstract endNamedEntity(): number;

    /**
     * Signal to the parser that the end of the input was reached.
     *
     * Remaining data will be emitted and relevant errors will be produced.
     * @returns The number of characters consumed.
     */
    end(): number {
        switch (this.state) {
            case STATE_NAMED_ENTITY: {
                return this.endNamedEntity();
            }

            // Otherwise, emit a numeric entity if we have one.
            case STATE_NUMERIC_DECIMAL: {
                return this.emitNumericEntity(0, 2);
            }

            case STATE_NUMERIC_HEX: {
                return this.emitNumericEntity(0, 3);
            }

            case STATE_NUMERIC_START: {
                this.errors?.absenceOfDigitsInNumericCharacterReference(
                    this.consumed,
                );
                return 0;
            }

            default: {
                // EntityStart: return 0.
                return 0;
            }
        }
    }
}

/**
 * Streaming decoder for HTML entities.
 *
 * When the entity fits inside the current chunk (the common case), the
 * lookup runs directly on the chunk via the same length-probe scheme as the
 * synchronous decoder; chunk-boundary runs use a reusable character buffer.
 */
export class HtmlEntityDecoder extends EntityDecoderBase {
    /** Total name characters seen for the current named entity. */
    private runLength = 0;

    /** Reused for ASCII alphanumerics; the 32nd character rules out an exact match. */
    private readonly nameBuffer = new Uint8Array(32);

    /**
     * Emit the replacement for a matched slot. Values are at most two
     * UTF-16 code units; like previous versions, each unit is emitted as
     * its own callback (surrogate halves included).
     * @param slot The matched slot.
     */
    private emitSlot(slot: number): void {
        const packed = htmlSlotValue[slot];
        const off = packed >> 2;
        this.emitCodePoint(htmlValues.charCodeAt(off), this.consumed);
        if ((packed & 1) !== 0) {
            this.emitCodePoint(htmlValues.charCodeAt(off + 1), this.consumed);
        }
    }

    override startEntity(decodeMode: DecodingMode): void {
        super.startEntity(decodeMode);
        this.runLength = 0;
    }

    protected stateNamedEntity(input: string, offset: number): number {
        const inputLength = input.length;

        if (this.runLength === 0 && offset + 17 <= inputLength) {
            /*
             * Fast path: all length probes are conclusive inside this
             * chunk. (Near the chunk end a probe miss could be a name
             * continuing in the next chunk, so fall through to buffering.)
             */
            const c0 = input.charCodeAt(offset);
            const pair = pairIndex(c0, input.charCodeAt(offset + 1));
            const bits = htmlLengthBits[pair];
            if ((bits & 0x80_00) !== 0 && offset + 32 <= inputLength) {
                const packed = findLongClassMatch(
                    input,
                    offset,
                    this.decodeMode,
                );
                if (packed >= 0) {
                    const length = packed & 63;
                    this.consumed = length + 1;
                    this.emitSlot(packed >> 6);
                    if (input.charCodeAt(offset + length - 1) !== CHAR_SEMI) {
                        this.errors?.missingSemicolonAfterCharacterReference();
                    }
                    return this.consumed;
                }
                // The window decides every exact or legacy candidate.
                return 0;
            }
            let probed = bits & 0x7f_ff;
            while (probed !== 0) {
                /*
                 * Shortest candidate first: only one length can carry the
                 * terminating ';' (a ';' inside a longer candidate fails its
                 * middle comparison), so the order is correctness-neutral,
                 * and the most common entities are short. Legacy matches are
                 * resolved after the loop, preserving exact-match
                 * precedence.
                 */
                const low = probed & -probed;
                probed ^= low;
                const length = 33 - Math.clz32(low);
                if (input.charCodeAt(offset + length) === CHAR_SEMI) {
                    const slot = findSlotHtml(input, offset, length);
                    if (slot >= 0) {
                        this.consumed = length + 2;
                        this.emitSlot(slot);
                        return this.consumed;
                    }
                }
            }
            if ((bits & 0x80_00) === 0) {
                if (this.decodeMode !== DecodingMode.Strict) {
                    const packed = findLegacySlot(input, offset, 31);
                    if (packed >= 0) {
                        const matchLength = packed & 7;
                        const next = input.charCodeAt(offset + matchLength);
                        if (
                            this.decodeMode === DecodingMode.Attribute &&
                            isEntityInAttributeInvalidEnd(next)
                        ) {
                            return 0;
                        }
                        this.consumed = matchLength + 1;
                        this.emitSlot(packed >> 3);
                        this.errors?.missingSemicolonAfterCharacterReference();
                        return this.consumed;
                    }
                }
                // No long names in this class: the probes were exhaustive.
                return 0;
            }
        }

        // A 32nd name character rules out every exact HTML match.
        let index = offset;
        let { runLength } = this;
        let terminator = -1;
        const scanEnd = Math.min(inputLength, offset + 32 - runLength);
        while (index < scanEnd) {
            const char = input.charCodeAt(index);
            if (!isAlphaNumeric(char)) {
                terminator = char;
                break;
            }
            this.nameBuffer[runLength++] = char;
            index++;
        }
        if (terminator < 0 && runLength < 32) {
            this.runLength = runLength;
            return -1;
        }
        if (terminator === CHAR_SEMI && (runLength - 2) >>> 0 <= 29) {
            const slot = findBufferedHtmlSlot(this.nameBuffer, runLength);
            if (slot >= 0) {
                this.consumed = runLength + 2;
                this.emitSlot(slot);
                return this.consumed;
            }
        }
        if (this.decodeMode !== DecodingMode.Strict && runLength >= 2) {
            const packed = findBufferedLegacySlot(this.nameBuffer, runLength);
            if (packed >= 0) {
                const length = packed & 7;
                const next =
                    length < runLength ? this.nameBuffer[length] : terminator;
                if (
                    this.decodeMode === DecodingMode.Attribute &&
                    isEntityInAttributeInvalidEnd(next)
                )
                    return 0;
                this.consumed = length + 1;
                this.emitSlot(packed >> 3);
                this.errors?.missingSemicolonAfterCharacterReference();
                return this.consumed;
            }
        }
        return 0;
    }

    protected endNamedEntity(): number {
        // Emit the longest legacy match in the buffered run, if any.
        if (this.decodeMode === DecodingMode.Strict || this.runLength < 2) {
            return 0;
        }
        const packed = findBufferedLegacySlot(this.nameBuffer, this.runLength);
        if (packed < 0) return 0;
        if (
            this.decodeMode === DecodingMode.Attribute &&
            (packed & 7) < this.runLength
        ) {
            return 0;
        }
        this.consumed = (packed & 7) + 1;
        this.emitSlot(packed >> 3);
        this.errors?.missingSemicolonAfterCharacterReference();
        return this.consumed;
    }
}

/**
 * Find a buffered name. Its characters were already checked as ASCII
 * alphanumerics while scanning, so no outer-character range check is needed.
 * @param buffer Reusable character buffer.
 * @param length Number of characters to match.
 */
function findBufferedHtmlSlot(buffer: Uint8Array, length: number): number {
    const key =
        (buffer[0] << 25) |
        (buffer[1] << 18) |
        (buffer[length - 2] << 11) |
        (CHAR_REMAP[buffer[length - 1]] << 5) |
        length;
    let slot =
        2 * (((Math.imul(key, BUCKET_HASH_1) >>> 16) * htmlBuckets) >>> 16);
    for (let attempt = 0; ; attempt++) {
        if (
            htmlKeys[slot] === key &&
            (length <= 4 || isBufferedMiddle(slot, buffer, length))
        )
            return slot;
        if (
            htmlKeys[slot + 1] === key &&
            (length <= 4 || isBufferedMiddle(slot + 1, buffer, length))
        )
            return slot + 1;
        if (attempt === 1) return -1;
        slot =
            2 * (((Math.imul(key, BUCKET_HASH_2) >>> 16) * htmlBuckets) >>> 16);
    }
}

function isBufferedMiddle(
    slot: number,
    buffer: Uint8Array,
    length: number,
): boolean {
    let wordIndex = htmlSlotMidOff[slot];
    let index = 2;
    const end = length - 2;
    while (index + 1 < end) {
        if (
            (buffer[index] | (buffer[index + 1] << 16)) !==
            htmlMiddles[wordIndex++]
        )
            return false;
        index += 2;
    }
    return (
        index === end || buffer[index] === (htmlMiddles[wordIndex] & 0xff_ff)
    );
}

/**
 * Find the longest legacy name in the buffer, packed as `(slot << 3) | length`.
 * @param buffer Reusable character buffer.
 * @param length Number of buffered characters.
 */
function findBufferedLegacySlot(buffer: Uint8Array, length: number): number {
    let legacy = (htmlLengthBits[pairIndex(buffer[0], buffer[1])] >>> 16) & 31;
    while (legacy !== 0) {
        const top = 31 - Math.clz32(legacy);
        legacy ^= 1 << top;
        if (top + 2 > length) continue;
        const slot = findBufferedHtmlSlot(buffer, top + 2);
        if (slot >= 0 && (htmlSlotValue[slot] & 2) !== 0)
            return (slot << 3) | (top + 2);
    }
    return -1;
}

/**
 * Match an XML name packed in seven-bit groups, or return -1.
 * @param name Packed name accumulated across chunks.
 */
function xmlCodePoint(name: number): number {
    switch (name) {
        case 0x18_76_f0: {
            // "amp"
            return 0x26;
        }
        case 0xc_3c_37_f3: {
            // "apos"
            return 0x27;
        }
        case 0x33_f4: {
            // "gt"
            return 0x3e;
        }
        case 0x36_74: {
            // "lt"
            return 0x3c;
        }
        case 0xe_3d_77_f4: {
            // "quot"
            return 0x22;
        }
        default: {
            return -1;
        }
    }
}

/**
 * Streaming decoder for XML entities: the five predefined named entities
 * plus numeric character references.
 */
export class XmlEntityDecoder extends EntityDecoderBase {
    protected override readonly replaceNumericCodePoint: (
        codePoint: number,
    ) => number = replaceCodePointXML;

    protected stateNamedEntity(input: string, offset: number): number {
        const inputLength = input.length;
        if (this.consumed === 1 && offset + 5 <= inputLength) {
            // Fast path: all five patterns are decided within five chars.
            const packed = matchXmlEntity(input, offset);
            if (packed < 0) return 0;
            const consumed = (this.consumed = (packed >> 7) + 1);
            this.emitCodePoint(packed & 127, consumed);
            return consumed;
        }

        // XML names need at most four seven-bit characters, fitting in 28 bits.
        let { result, consumed } = this;
        for (let index = offset; index < inputLength; index++) {
            const char = input.charCodeAt(index);
            if (char === CHAR_SEMI) {
                const codePoint = xmlCodePoint(result);
                if (codePoint < 0) return 0;
                this.consumed = consumed + 1;
                this.emitCodePoint(codePoint, this.consumed);
                return this.consumed;
            }
            if (consumed >= 5 || (char - CHAR_LOWER_A) >>> 0 > 25) return 0;
            result = (result << 7) | char;
            consumed++;
        }
        this.result = result;
        this.consumed = consumed;
        return -1;
    }

    protected endNamedEntity(): number {
        // XML has no legacy entities; an unterminated name never matches.
        return 0;
    }
}

export { replaceCodePoint, replaceCodePointXML } from "./decode-codepoint.js";
