# SPDX-License-Identifier: GPL-3.0-or-later
"""Cut eSpeak NG's wasm to the French, Italian and Spanish data the voices on the device need
(app/assets/vendor/stimme-daten/espeak-ng.wasm).

    python app/assets/vendor/stimme/stimme_espeak.py <espeak-ng.wasm> <out.wasm>
    python app/assets/vendor/stimme/stimme_espeak.py <espeak-ng.wasm> --list         # the embedded files, largest first

Source: the npm package espeak-ng 1.0.2 (https://registry.npmjs.org/espeak-ng/-/espeak-ng-1.0.2.tgz, sha256
01ba93df87f0e8deefd01803a64f299a42b8ded400a5b6673b1ae226b90abc05; built from https://github.com/ianmarmour/espeak-ng.js
at eec88dbcc1482b1a03a76cf9d2025a0acde8982e, eSpeak NG 1.52-dev, https://github.com/espeak-ng/espeak-ng), GPL-3.0-or-later.
Its package/dist/espeak-ng.wasm (sha256 10d24bb7…d532, 18,485,010 bytes) is the input; its espeak-ng.js is shipped
unchanged (app/assets/vendor/stimme/espeak-ng.js). This script is published next to it (/lizenzen links it): it is
how the shipped wasm was made from the GPL original.

What it removes: emscripten embedded espeak-ng-data/ (445 files, 18 MB) into the wasm's data section. This keeps
phondata, phontab, phonindex, intonations, fr_dict, it_dict, es_dict, en_dict and the language files roa/fr, roa/it,
roa/es and gmw/en (fr_dict hands English loanwords like "week-end" or "computer" to en_dict, so French needs en too),
and drops every other file:
their bytes and table entries are zeroed and the data section is re-emitted as active segments over the non-zero runs
only (wasm memory starts zeroed, so the result is exact). The code section is untouched. The output's sha256 must be
3b8b999fef1a0a970a14def966d6713da9cae0c41c7f2bc4c9e6761d7c21d060 (1,488,516 bytes): the script checks both.

How to verify the phonemes: run eSpeak from both wasm files on the same texts and compare the IPA (`--ipa -q -v fr-fr`,
`-v it`, `-v es`, as app/assets/vendor/stimme/espeak.js calls it). The cut changed none of 12 test texts (sentences,
numbers, English loanwords in each language); without en_dict two French ones differed, spelled out letter by letter. A
word whose rules live in another language's dictionary would differ. If espeak-ng.js is ever rebuilt, its table address (TABLE) changes: read it from the line
`___emscripten_embedded_file_data = ` … in espeak-ng.js and update both checksums.
"""

import hashlib
import re
import struct
import sys
from pathlib import Path

TABLE = 18226032   # ___emscripten_embedded_file_data in espeak-ng.js 1.0.2
KEEP = [r"espeak-ng-data/(phondata|phontab|phonindex|intonations)$", r"espeak-ng-data/(fr|it|es|en)_dict$",
        r"espeak-ng-data/lang/roa/(fr|it|es)$", r"espeak-ng-data/lang/gmw/en$"]
SOURCE_SHA256 = "10d24bb7e4124e983aa9cd8cd96c52c8ea4b607d81956ec70846684e2827d532"
OUT_SHA256 = "3b8b999fef1a0a970a14def966d6713da9cae0c41c7f2bc4c9e6761d7c21d060"


def uleb(b, i):
    r = s = 0
    while True:
        x = b[i]
        i += 1
        r |= (x & 0x7F) << s
        s += 7
        if not x & 0x80:
            return r, i


def sleb(b, i):
    r = s = 0
    while True:
        x = b[i]
        i += 1
        r |= (x & 0x7F) << s
        s += 7
        if not x & 0x80:
            if x & 0x40:
                r -= 1 << s
            return r, i


def enc_uleb(n):
    out = bytearray()
    while True:
        x, n = n & 0x7F, n >> 7
        out.append(x | (0x80 if n else 0))
        if not n:
            return bytes(out)


def enc_sleb(n):
    out = bytearray()
    while True:
        x, n = n & 0x7F, n >> 7
        done = (n == 0 and not x & 0x40) or (n == -1 and x & 0x40)
        out.append(x | (0 if done else 0x80))
        if done:
            return bytes(out)


def sections(b):
    i = 8
    while i < len(b):
        sid = b[i]
        size, j = uleb(b, i + 1)
        yield sid, i, j, j + size
        i = j + size


def read_data(b):
    """(start, end, [(offset, bytes)]) of the data section (id 11): active segments of memory 0 only."""
    for sid, start, body, end in sections(b):
        if sid == 11:
            n, i = uleb(b, body)
            segs = []
            for _ in range(n):
                flag, i = uleb(b, i)
                if flag != 0:
                    sys.exit(f"only active memory-0 segments are supported, got flag {flag}")
                assert b[i] == 0x41
                off, i = sleb(b, i + 1)
                assert b[i] == 0x0B
                ln, i = uleb(b, i + 1)
                segs.append((off, b[i:i + ln]))
                i += ln
            return start, end, segs
    sys.exit("no data section")


def image(segs):
    mem = bytearray(max(o + len(d) for o, d in segs))
    for o, d in segs:
        mem[o:o + len(d)] = d
    return mem


def table(mem, addr):
    """The embedded files: [(name, length, content address, name address)], and the table's end."""
    out, p = [], addr
    while True:
        name, ln, content = struct.unpack_from("<III", mem, p)
        out.append((mem[name:mem.index(0, name)].decode(), ln, content, name))
        p += 12
        if struct.unpack_from("<I", mem, p)[0] == 0:
            return out, p


def shrink(b: bytearray, keep_patterns: list[str]) -> bytes:
    dstart, dend, segs = read_data(b)
    mem = image(segs)
    files, tend = table(mem, TABLE)
    pats = [re.compile(p) for p in keep_patterns]
    keep = [f for f in files if any(p.search(f[0]) for p in pats)]
    for name, ln, content, naddr in (f for f in files if f not in keep):
        mem[content:content + ln] = bytes(ln)
        mem[naddr:naddr + len(name) + 1] = bytes(len(name) + 1)
    mem[TABLE:tend + 4] = bytes(tend + 4 - TABLE)
    p = TABLE
    for _name, ln, content, naddr in keep:
        struct.pack_into("<III", mem, p, naddr, ln, content)
        p += 12
    # re-emit: runs of non-zero bytes, joined across zero gaps shorter than 32 bytes
    runs, n = [], len(mem)
    i = min(o for o, _ in segs)
    while i < n:
        while i < n and mem[i] == 0:
            i += 1
        if i >= n:
            break
        j = i
        while j < n:
            if mem[j]:
                j += 1
                continue
            k = j
            while k < n and mem[k] == 0 and k - j < 32:
                k += 1
            if k < n and mem[k] and k - j < 32:
                j = k
            else:
                break
        runs.append((i, bytes(mem[i:j])))
        i = j
    body = bytearray(enc_uleb(len(runs)))
    for o, d in runs:
        body += b"\x00\x41" + enc_sleb(o) + b"\x0b" + enc_uleb(len(d)) + d
    new = b[:dstart] + bytes([11]) + enc_uleb(len(body)) + body + b[dend:]
    for sid, start, _bstart, end in sections(new):   # the DataCount section (id 12) names the segment count
        if sid == 12:
            new = new[:start] + bytes([12]) + enc_uleb(len(enc_uleb(len(runs)))) + enc_uleb(len(runs)) + new[end:]
            break
    print(f"kept {len(keep)} of {len(files)} files ({sum(f[1] for f in keep):,} bytes); {len(runs)} segments; "
          f"{len(b):,} → {len(new):,} bytes")
    return bytes(new)


def main() -> None:
    if len(sys.argv) != 3:
        sys.exit(__doc__.split("\n\n")[1])
    b = bytearray(Path(sys.argv[1]).read_bytes())
    if (got := hashlib.sha256(b).hexdigest()) != SOURCE_SHA256:
        sys.exit(f"{sys.argv[1]}: sha256 {got}, not espeak-ng 1.0.2's dist/espeak-ng.wasm")
    if sys.argv[2] == "--list":
        files, _ = table(image(read_data(b)[2]), TABLE)
        for name, ln, _, _ in sorted(files, key=lambda f: -f[1]):
            print(f"{ln:>10,} {name}")
        return
    out = shrink(b, KEEP)
    if (got := hashlib.sha256(out).hexdigest()) != OUT_SHA256:
        sys.exit(f"output sha256 {got}, expected {OUT_SHA256}: not written")
    Path(sys.argv[2]).write_bytes(out)
    print(f"wrote {sys.argv[2]} (sha256 {OUT_SHA256})")


if __name__ == "__main__":
    main()
