// EntityEncoder.js
import { buildTries } from './entityTries.js';
// NOTE: no static import of ALL_ENTITIES / entities.js here — that import
// was the actual tree-shaking leak (see constructor comment below).

// Replacement strings indexed by char code — direct array access, no hashing
const XML_UNSAFE_REPLACEMENT = new Array(128);
XML_UNSAFE_REPLACEMENT[38] = '&amp;';   // &
XML_UNSAFE_REPLACEMENT[60] = '&lt;';    // <
XML_UNSAFE_REPLACEMENT[62] = '&gt;';    // >
XML_UNSAFE_REPLACEMENT[34] = '&quot;';  // "
XML_UNSAFE_REPLACEMENT[39] = '&apos;';  // '

// Typed bitmask for O(1) "is this ASCII code XML-unsafe?" check
const IS_XML_UNSAFE = new Uint8Array(128);
IS_XML_UNSAFE[38] = 1;
IS_XML_UNSAFE[60] = 1;
IS_XML_UNSAFE[62] = 1;
IS_XML_UNSAFE[34] = 1;
IS_XML_UNSAFE[39] = 1;

// Fast pre-scan: bail out immediately if nothing needs encoding
const NEEDS_PROCESSING = /[&<>"'\u0080-\uFFFF]/;

export default class EntityEncoder {
  constructor(options = {}) {
    this.encodeXmlSafe = options.encodeXmlSafe !== false;
    this.encodeAllNamed = options.encodeAllNamed !== false;
    this.maxReplacements = options.maxReplacements || 0;
    this.replacementsCount = 0;

    // Tries are only built when named-entity encoding is actually requested,
    // and only from what the caller explicitly hands in. There is no
    // built-in default entity set imported here — that static import was
    // exactly what pinned the entire ALL_ENTITIES data (all categories) into
    // every consumer's bundle regardless of what they actually used. A `||`
    // fallback to a statically-imported default does NOT tree-shake: the
    // import is still "used" from the bundler's point of view even if the
    // runtime branch never executes.
    //
    // BREAKING CHANGE from earlier versions: `new EntityEncoder()` with no
    // `namedEntities` and `encodeAllNamed !== false` now throws instead of
    // silently encoding the full built-in set. Callers who want the old
    // "encode everything" behavior must opt in explicitly:
    //   import { ALL_ENTITIES } from '@nodable/entities';
    //   new EntityEncoder({ namedEntities: ALL_ENTITIES });
    if (this.encodeAllNamed) {
      if (!options.namedEntities) {
        throw new Error(
          '[EntityEncoder] encodeAllNamed is true but no `namedEntities` was ' +
          'provided. Pass a specific set (e.g. { ...COMMON_HTML, ...CURRENCY }) ' +
          'or the full built-in set via `namedEntities: ALL_ENTITIES` imported ' +
          'from \'@nodable/entities\'. This is required so bundlers can ' +
          'tree-shake unused entity categories out of your build; set ' +
          '`encodeAllNamed: false` if you don\'t need named-entity encoding at all.'
        );
      }
      const tries = buildTries(options.namedEntities);
      this.trie1 = tries.trie1;
      this.trie2 = tries.trie2;
      this.trie3 = tries.trie3;
    } else {
      this.trie1 = null;
      this.trie2 = null;
      this.trie3 = null;
    }
  }

  encode(str) {
    if (typeof str !== 'string' || str.length === 0) return str;
    if (!NEEDS_PROCESSING.test(str)) return str;

    const maxRep = this.maxReplacements;
    if (maxRep > 0 && this.replacementsCount >= maxRep) return str;

    // Hoist to locals — avoids `this` property lookup inside the hot loop
    const encodeXmlSafe = this.encodeXmlSafe;
    const encodeAllNamed = this.encodeAllNamed;
    const trie1 = this.trie1;
    const trie2 = this.trie2;
    const trie3 = this.trie3;

    const len = str.length;

    let result = '';
    let last = 0;
    let i = 0;
    let limitReached = false;

    // ── Main loop: runs to len-2 so trie3 never needs a bounds check ────────
    // The last 2 characters are handled by the tail block below.
    const mainEnd = len - 2;  // i <= mainEnd guarantees i+1 and i+2 are valid

    while (i <= mainEnd && !limitReached) {
      const c0 = str.charCodeAt(i);

      // ── ASCII branch ───────────────────────────────────────────────────
      if (c0 < 128) {
        if (encodeXmlSafe && IS_XML_UNSAFE[c0] === 1) {
          result += str.substring(last, i) + XML_UNSAFE_REPLACEMENT[c0];
          last = ++i;
          if (maxRep > 0) {
            this.replacementsCount++;
            if (this.replacementsCount >= maxRep) {
              limitReached = true;
              break;
            }
          }
        } else {
          // Bulk-skip: advance to the next interesting position without
          // touching the outer loop overhead on every safe character
          i++;
          while (i <= mainEnd && !limitReached) {
            const c = str.charCodeAt(i);
            if (c >= 128 || (encodeXmlSafe && IS_XML_UNSAFE[c] === 1)) break;
            i++;
          }
        }
        continue;
      }

      // ── Non-ASCII: integer-keyed trie lookup ───────────────────────────
      // No bounds checks needed for c1/c2 because i <= mainEnd guarantees
      // i+1 and i+2 are both within the string.
      let matchedEntity = null;
      let advance = 1;

      if (encodeAllNamed) {
        // Try 3-char match first (longest wins)
        const mid3 = trie3.get(c0);
        if (mid3 !== undefined) {
          const c1 = str.charCodeAt(i + 1);
          const inner3 = mid3.get(c1);
          if (inner3 !== undefined) {
            const c2 = str.charCodeAt(i + 2);
            const candidate = inner3.get(c2);
            if (candidate !== undefined) { matchedEntity = candidate; advance = 3; }
          }
        }

        // Try 2-char match
        if (matchedEntity === null) {
          const inner2 = trie2.get(c0);
          if (inner2 !== undefined) {
            const c1 = str.charCodeAt(i + 1);
            const candidate = inner2.get(c1);
            if (candidate !== undefined) { matchedEntity = candidate; advance = 2; }
          }
        }

        // Try 1-char match
        if (matchedEntity === null) {
          const candidate = trie1.get(c0);
          if (candidate !== undefined) { matchedEntity = candidate; }
        }
      }

      if (matchedEntity !== null) {
        result += str.substring(last, i) + matchedEntity;
        i += advance;
        last = i;
        if (maxRep > 0) {
          this.replacementsCount++;
          if (this.replacementsCount >= maxRep) {
            limitReached = true;
            break;
          }
        }
      } else {
        i++;
      }
    }

    // ── Tail: handle the last 1-2 characters (no 3-char match possible) ────
    while (i < len && !limitReached) {
      const c0 = str.charCodeAt(i);

      if (c0 < 128) {
        if (encodeXmlSafe && IS_XML_UNSAFE[c0] === 1) {
          result += str.substring(last, i) + XML_UNSAFE_REPLACEMENT[c0];
          last = ++i;
          if (maxRep > 0) {
            this.replacementsCount++;
            if (this.replacementsCount >= maxRep) {
              limitReached = true;
              break;
            }
          }
        } else {
          i++;
        }
        continue;
      }

      // Non-ASCII tail — only 2-char and 1-char matches are possible here
      let matchedEntity = null;
      let advance = 1;

      if (encodeAllNamed) {
        if (i + 1 < len) {
          const inner2 = trie2.get(c0);
          if (inner2 !== undefined) {
            const c1 = str.charCodeAt(i + 1);
            const candidate = inner2.get(c1);
            if (candidate !== undefined) { matchedEntity = candidate; advance = 2; }
          }
        }

        if (matchedEntity === null) {
          const candidate = trie1.get(c0);
          if (candidate !== undefined) { matchedEntity = candidate; }
        }
      }

      if (matchedEntity !== null) {
        result += str.substring(last, i) + matchedEntity;
        i += advance;
        last = i;
        if (maxRep > 0) {
          this.replacementsCount++;
          if (this.replacementsCount >= maxRep) {
            limitReached = true;
            break;
          }
        }
      } else {
        i++;
      }
    }

    // ── Flush any remaining literal suffix ────────────────────────────────
    if (last < len) result += str.substring(last);
    return result;
  }

  reset() {
    this.replacementsCount = 0;
  }
}