// src/utils.ts
var UA_VOWELS = /* @__PURE__ */ new Set([..."\u0430\u0435\u0454\u0438\u0456\u0457\u043E\u0443\u044E\u044F"]);
var CORRECT_APOSTROPHE = "\u02BC";
var WRONG_APOSTROPHES = ["\u2019", "'", "\u02BB", "`", "\xB4"];
function normaliseApostrophe(text) {
  let t = text;
  for (const ch of WRONG_APOSTROPHES) {
    t = t.replaceAll(ch, CORRECT_APOSTROPHE);
  }
  return t;
}
function applyStressMark(word, vowelIndex) {
  let count = 0;
  for (let i = 0; i < word.length; i++) {
    if (UA_VOWELS.has(word[i].toLowerCase())) {
      if (count === vowelIndex) {
        return word.slice(0, i + 1) + "\u0301" + word.slice(i + 1);
      }
      count++;
    }
  }
  return word;
}
function normWord(word) {
  let w = normaliseApostrophe(word.toLowerCase());
  w = w.normalize("NFD").replace(/\p{Mn}/gu, "");
  return w;
}

// src/UaStressTrie.ts
var MAGIC = 1414744917;
var SUPPORTED_VERSIONS = /* @__PURE__ */ new Set([1, 2]);
var HEADER_SIZE = 20;
var ALPHABET_ENTRY_SIZE = 3;
var NODE_SIZE = 8;
var NO_STRESS = 255;
var FLAG_V1_UNCERTAIN = 1;
var FLAG_VARIATIVE = 1;
var FLAG_HAS_CHILDREN = 2;
var FLAG_HETERONYM = 4;
var FLAG_HAS_SECONDARY = 8;
async function decompressGzip(compressed) {
  if (typeof DecompressionStream !== "undefined") {
    const stream = new Blob([compressed]).stream().pipeThrough(
      new DecompressionStream("gzip")
    );
    return new Response(stream).arrayBuffer();
  }
  const zlib = await import("zlib");
  const result = zlib.gunzipSync(Buffer.from(compressed));
  return result.buffer.slice(
    result.byteOffset,
    result.byteOffset + result.byteLength
  );
}
var UaStressTrie = class _UaStressTrie {
  /**
   * Construct from a raw (already-decompressed) `.ctrie` `ArrayBuffer`.
   *
   * You normally don't call this directly — use a factory method instead.
   */
  constructor(buffer, gzSizeBytes = 0) {
    /** Per-variant morphological data loaded via `loadVariants()`. */
    this._variants = null;
    const view = new DataView(buffer);
    const magic = view.getUint32(0, true);
    if (magic !== MAGIC) {
      throw new Error(
        `Not a .ctrie file \u2014 bad magic: 0x${magic.toString(16).toUpperCase()} (expected 0x${MAGIC.toString(16).toUpperCase()})`
      );
    }
    const version = view.getUint8(4);
    if (!SUPPORTED_VERSIONS.has(version)) {
      throw new Error(
        `Unsupported .ctrie version: ${version} (supported: ${[...SUPPORTED_VERSIONS].join(", ")})`
      );
    }
    this._nodeCount = view.getUint32(6, true);
    this._wordCount = view.getUint32(10, true);
    this._alphaSize = view.getUint32(14, true);
    this._gzSizeBytes = gzSizeBytes;
    this._version = version;
    this._charToId = /* @__PURE__ */ new Map();
    let offset = HEADER_SIZE;
    for (let i = 0; i < this._alphaSize; i++) {
      const cp = view.getUint16(offset + 1, true);
      this._charToId.set(cp, i);
      offset += ALPHABET_ENTRY_SIZE;
    }
    this._nodesOffset = offset;
    this._view = view;
  }
  // ── Factory methods ─────────────────────────────────────────────────────────
  /**
   * Fetch a `.ctrie` or `.ctrie.gz` file and parse it into a `UaStressTrie`.
   *
   * Automatically detects and decompresses gzip data (works both with and
   * without `Content-Encoding: gzip` on the server).
   *
   * **Recommended serving setup** (avoids client-side decompression work):
   * ```
   * Content-Encoding: gzip
   * Content-Type: application/octet-stream
   * ```
   *
   * @param url - URL to `.ctrie.gz` (or raw `.ctrie`)
   */
  static async fromUrl(url) {
    const resp = await fetch(url);
    if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
    const contentEncoding = resp.headers.get("content-encoding");
    if (contentEncoding?.toLowerCase().includes("gzip")) {
      const buf = await resp.arrayBuffer();
      return new _UaStressTrie(buf);
    }
    const rawBuf = await resp.arrayBuffer();
    const bytes = new Uint8Array(rawBuf);
    if (bytes[0] === 31 && bytes[1] === 139) {
      const stream = new Blob([rawBuf]).stream().pipeThrough(
        new DecompressionStream("gzip")
      );
      const buf = await new Response(stream).arrayBuffer();
      return new _UaStressTrie(buf, rawBuf.byteLength);
    }
    return new _UaStressTrie(rawBuf);
  }
  /**
   * Load from a local file path (**Node.js only**).
   *
   * Handles both `.ctrie.gz` (gzip-compressed) and raw `.ctrie` files.
   *
   * @param filePath - Absolute or relative path to the data file
   */
  static async fromFile(filePath) {
    const fs = await import("fs");
    const zlib = await import("zlib");
    const raw = fs.readFileSync(filePath);
    const isGzip = raw[0] === 31 && raw[1] === 139;
    if (isGzip) {
      const decompressed = zlib.gunzipSync(raw);
      const ab2 = decompressed.buffer.slice(
        decompressed.byteOffset,
        decompressed.byteOffset + decompressed.byteLength
      );
      return new _UaStressTrie(ab2, raw.byteLength);
    }
    const ab = raw.buffer.slice(
      raw.byteOffset,
      raw.byteOffset + raw.byteLength
    );
    return new _UaStressTrie(ab);
  }
  /**
   * Parse from an already-decompressed `ArrayBuffer`.
   *
   * Useful when you loaded the binary data yourself (e.g. from a Service
   * Worker cache, IndexedDB, or a custom fetch wrapper).
   *
   * @param buffer - Raw, decompressed `.ctrie` bytes
   */
  static fromBuffer(buffer) {
    return new _UaStressTrie(buffer);
  }
  // ── Variants (supplementary morphological data) ──────────────────────────
  /**
   * Load per-variant morphological data from `ua_stress.variants.json.gz`.
   *
   * After calling this, `lookupFull()` will populate the `variants` field for
   * all words with multiple stress positions (heteronyms and variatives).
   *
   * @example
   * // Browser
   * await trie.loadVariants('/static/ua_stress.variants.json.gz')
   *
   * // Node.js
   * import { readFileSync } from 'fs'
   * const gz = readFileSync('./data/ua_stress.variants.json.gz')
   * await trie.loadVariants(gz.buffer)
   */
  async loadVariants(source) {
    let compressed;
    if (typeof source === "string") {
      const res = await fetch(source);
      if (!res.ok)
        throw new Error(`Failed to fetch variants: ${res.status} ${source}`);
      compressed = await res.arrayBuffer();
    } else {
      compressed = source;
    }
    const decompressed = await decompressGzip(compressed);
    const json = new TextDecoder().decode(decompressed);
    const raw = JSON.parse(json);
    this._variants = new Map(Object.entries(raw));
  }
  /**
   * Load variants from a Node.js file path (convenience wrapper).
   * Prefer `loadVariants(buffer)` in environments where you already have the bytes.
   */
  static async loadVariantsFromFile(trie, filePath) {
    const fs = await import("fs");
    const buf = fs.readFileSync(filePath);
    const ab = buf.buffer.slice(
      buf.byteOffset,
      buf.byteOffset + buf.byteLength
    );
    await trie.loadVariants(ab);
  }
  /** Whether per-variant morphological data has been loaded. */
  get variantsLoaded() {
    return this._variants !== null;
  }
  // ── Public API ──────────────────────────────────────────────────────────────
  /** Total number of word forms stored in the trie. */
  get wordCount() {
    return this._wordCount;
  }
  /** Total number of internal nodes (structural metric). */
  get nodeCount() {
    return this._nodeCount;
  }
  /** Metadata summary (wordCount, nodeCount, gzSizeBytes). */
  get stats() {
    return {
      wordCount: this._wordCount,
      nodeCount: this._nodeCount,
      gzSizeBytes: this._gzSizeBytes
    };
  }
  /**
   * Look up the stressed vowel index (0-based) for a word.
   *
   * Returns `null` if the word is not in the trie — use an ONNX-based
   * fallback (e.g. Luscinia) for OOV words.
   *
   * Apostrophe variants and casing are normalised automatically.
   *
   * @example
   * trie.lookup('університет')   // → 4
   * trie.lookup('мама')          // → 0
   * trie.lookup('unknownword')   // → null
   */
  lookup(word) {
    return this.lookupFull(word)?.stress ?? null;
  }
  /**
   * Full lookup returning all stress positions and type classification.
   *
   * @returns `LookupResult` or `null` if the word is not in the database.
   *
   * The `type` field tells you how to interpret multiple stress positions:
   *
   * - `"unique"`    — one stress, no ambiguity.
   * - `"variative"` — both positions in `stresses[]` are orthographically
   *                    valid simultaneously (e.g. _по́милка_ / _поми́лка_).
   *                    Present both to the user, or pick either — both are correct.
   * - `"heteronym"` — different positions correspond to different meanings
   *                    or grammatical forms (e.g. _за́мок_ "lock" vs _замо́к_ "castle";
   *                    _бло́хи_ gen.sg vs _блохи́_ nom.pl).
   *                    Context-aware disambiguation is needed to pick the right one.
   *
   * @example
   * trie.lookupFull('університет')
   * // → { stress: 4, stresses: [4], type: 'unique', uncertain: false }
   *
   * trie.lookupFull('помилка')
   * // → { stress: 0, stresses: [0, 1], type: 'variative', uncertain: true }
   *
   * trie.lookupFull('замок')
   * // → { stress: 0, stresses: [0, 1], type: 'heteronym', uncertain: true }
   *
   * trie.lookupFull('xyz123')
   * // → null
   */
  lookupFull(word) {
    const norm = normWord(word);
    let nodeIdx = 0;
    for (const ch of norm) {
      const cid = this._charToId.get(ch.codePointAt(0));
      if (cid === void 0) return null;
      const childIdx = this._findChild(nodeIdx, cid);
      if (childIdx === -1) return null;
      nodeIdx = childIdx;
    }
    const stress = this._nodeByte(nodeIdx, 1);
    if (stress === NO_STRESS) return null;
    const flags = this._nodeByte(nodeIdx, 2);
    let stresses;
    let type;
    if (this._version >= 2) {
      const stress2 = this._nodeByte(nodeIdx, 3);
      stresses = (flags & FLAG_HAS_SECONDARY) !== 0 && stress2 !== NO_STRESS ? [stress, stress2] : [stress];
      if (flags & FLAG_VARIATIVE) type = "variative";
      else if (flags & FLAG_HETERONYM) type = "heteronym";
      else type = "unique";
    } else {
      stresses = [stress];
      type = (flags & FLAG_V1_UNCERTAIN) !== 0 ? "heteronym" : "unique";
    }
    const variants = this._variants?.get(norm) ?? null;
    return { stress, stresses, type, variants, uncertain: type !== "unique" };
  }
  /**
   * Return the word with a combining acute accent (U+0301) on the stressed
   * vowel, or `null` if the word is not in the trie.
   *
   * The accent is inserted *after* the stressed vowel so it renders correctly
   * in all Unicode-compliant environments.
   *
   * @example
   * trie.mark('університет') // → 'університе\u0301т'
   * trie.mark('мама')        // → 'ма\u0301ма'
   * trie.mark('xyz123')      // → null
   */
  mark(word) {
    const result = this.lookupFull(word);
    if (result === null) return null;
    return applyStressMark(word, result.stress);
  }
  /**
   * Batch `lookup` — stress index for each word.  Returns `null` for unknowns.
   *
   * Prefer this over calling `lookup()` in a loop: a single method call
   * avoids repeated property lookups and is easier to pipeline.
   *
   * @example
   * trie.lookupBatch(['мама', 'тато', 'xyz'])  // → [0, 0, null]
   */
  lookupBatch(words) {
    return words.map((w) => this.lookup(w));
  }
  /** Alias for {@link lookupBatch}. */
  lookupMany(words) {
    return this.lookupBatch(words);
  }
  /**
   * Batch `lookupFull` — full result for each word.  Returns `null` for unknowns.
   *
   * @example
   * trie.lookupFullBatch(['замок', 'xyz'])
   * // → [{ stress: 0, stresses: [0,1], type: 'heteronym', uncertain: true }, null]
   */
  lookupFullBatch(words) {
    return words.map((w) => this.lookupFull(w));
  }
  /** Alias for {@link lookupFullBatch}. */
  lookupFullMany(words) {
    return this.lookupFullBatch(words);
  }
  /**
   * Batch `mark` — returns each word with an acute accent on the stressed vowel,
   * or `null` for words not in the dictionary.
   *
   * @example
   * trie.markBatch(['мама', 'університет', 'xyz'])
   * // → ['ма\u0301ма', 'університе\u0301т', null]
   */
  markBatch(words) {
    return words.map((w) => this.mark(w));
  }
  /** Alias for {@link markBatch}. */
  markMany(words) {
    return this.markBatch(words);
  }
  // ── Private helpers ─────────────────────────────────────────────────────────
  _nodeByte(nodeIdx, byteOffset) {
    return this._view.getUint8(
      this._nodesOffset + nodeIdx * NODE_SIZE + byteOffset
    );
  }
  _firstChild(nodeIdx) {
    return this._view.getUint32(
      this._nodesOffset + nodeIdx * NODE_SIZE + 4,
      true
    );
  }
  /**
   * Find the child of `parentIdx` whose char_id equals `targetId`.
   *
   * Children are contiguous in BFS order and sorted by char_id (ascending),
   * so we stop early when `char_id > targetId`.
   *
   * Returns the child node index, or -1 if not found.
   */
  _findChild(parentIdx, targetId) {
    if (!(this._nodeByte(parentIdx, 2) & FLAG_HAS_CHILDREN)) return -1;
    const fc = this._firstChild(parentIdx);
    if (fc === 0) return -1;
    for (let i = 0; i < this._alphaSize; i++) {
      const idx = fc + i;
      if (idx >= this._nodeCount) break;
      const cid = this._nodeByte(idx, 0);
      if (cid === targetId) return idx;
      if (cid > targetId) break;
    }
    return -1;
  }
};
export {
  CORRECT_APOSTROPHE,
  UA_VOWELS,
  UaStressTrie,
  applyStressMark,
  normWord,
  normaliseApostrophe
};
//# sourceMappingURL=index.js.map