const decoder = new TextDecoder();
const decode = decoder.decode.bind(decoder);

const concatTypedArray = (a, b) => {
  const result = new a.constructor(a.length + b.length);
  result.set(a);
  result.set(b, a.length);
  return result;
};

const strcmp = (a, b) => {
  (a = a.toLowerCase()), (b = b.toLowerCase());
  return a < b ? -1 : a > b ? 1 : 0;
};

class DictZip {
  #chlen;
  #chunks;
  #compressed;
  inflate;
  async load(file) {
    const header = new DataView(await file.slice(0, 12).arrayBuffer());
    if (header.getUint8(0) !== 31 || header.getUint8(1) !== 139 || header.getUint8(2) !== 8)
      throw new Error("Not a DictZip file");
    const flg = header.getUint8(3);
    if (!flg & 0b100) throw new Error("Missing FEXTRA flag");

    const xlen = header.getUint16(10, true);
    const extra = new DataView(await file.slice(12, 12 + xlen).arrayBuffer());
    if (extra.getUint8(0) !== 82 || extra.getUint8(1) !== 65)
      throw new Error("Subfield ID should be RA");
    if (extra.getUint16(4, true) !== 1) throw new Error("Unsupported version");

    this.#chlen = extra.getUint16(6, true);
    const chcnt = extra.getUint16(8, true);
    this.#chunks = [];
    for (let i = 0, chunkOffset = 0; i < chcnt; i++) {
      const chunkSize = extra.getUint16(10 + 2 * i, true);
      this.#chunks.push([chunkOffset, chunkSize]);
      chunkOffset = chunkOffset + chunkSize;
    }

    // skip to compressed data
    let offset = 12 + xlen;
    const max = Math.min(offset + 512, file.size);
    const strArr = new Uint8Array(await file.slice(0, max).arrayBuffer());
    if (flg & 0b1000) {
      // fname
      const i = strArr.indexOf(0, offset);
      if (i < 0) throw new Error("Header too long");
      offset = i + 1;
    }
    if (flg & 0b10000) {
      // fcomment
      const i = strArr.indexOf(0, offset);
      if (i < 0) throw new Error("Header too long");
      offset = i + 1;
    }
    if (flg & 0b10) offset += 2; // fhcrc
    this.#compressed = file.slice(offset);
  }
  async read(offset, size) {
    const chunks = this.#chunks;
    const startIndex = Math.trunc(offset / this.#chlen);
    const endIndex = Math.trunc((offset + size) / this.#chlen);
    const buf = await this.#compressed
      .slice(chunks[startIndex][0], chunks[endIndex][0] + chunks[endIndex][1])
      .arrayBuffer();
    let arr = new Uint8Array();
    for (let pos = 0, i = startIndex; i <= endIndex; i++) {
      const data = new Uint8Array(buf, pos, chunks[i][1]);
      arr = concatTypedArray(arr, await this.inflate(data));
      pos += chunks[i][1];
    }
    const startOffset = offset - startIndex * this.#chlen;
    return arr.subarray(startOffset, startOffset + size);
  }
}

class Index {
  strcmp = strcmp;
  // binary search
  bisect(query, start = 0, end = this.words.length - 1) {
    if (end - start === 1) {
      if (!this.strcmp(query, this.getWord(start))) return start;
      if (!this.strcmp(query, this.getWord(end))) return end;
      return null;
    }
    const mid = Math.floor(start + (end - start) / 2);
    const cmp = this.strcmp(query, this.getWord(mid));
    if (cmp < 0) return this.bisect(query, start, mid);
    if (cmp > 0) return this.bisect(query, mid, end);
    return mid;
  }
  // check for multiple definitions
  checkAdjacent(query, i) {
    if (i == null) return [];
    let j = i;
    const equals = (i) => {
      const word = this.getWord(i);
      return word ? this.strcmp(query, word) === 0 : false;
    };
    while (equals(j - 1)) j--;
    let k = i;
    while (equals(k + 1)) k++;
    return j === k ? [i] : Array.from({ length: k + 1 - j }, (_, i) => j + i);
  }
  lookup(query) {
    return this.checkAdjacent(query, this.bisect(query));
  }
}

const decodeBase64Number = (str) => {
  const { length } = str;
  let n = 0;
  for (let i = 0; i < length; i++) {
    const c = str.charCodeAt(i);
    n +=
      (c === 43
        ? 62 // "+"
        : c === 47
          ? 63 // "/"
          : c < 58
            ? c + 4 // 0-9 -> 52-61
            : c < 91
              ? c - 65 // A-Z -> 0-25
              : c - 71) * // a-z -> 26-51
      64 ** (length - 1 - i);
  }
  return n;
};

class DictdIndex extends Index {
  getWord(i) {
    return this.words[i];
  }
  async load(file) {
    const words = [];
    const offsets = [];
    const sizes = [];
    for (const line of decode(await file.arrayBuffer()).split("\n")) {
      const a = line.split("\t");
      words.push(a[0]);
      offsets.push(decodeBase64Number(a[1]));
      sizes.push(decodeBase64Number(a[2]));
    }
    this.words = words;
    this.offsets = offsets;
    this.sizes = sizes;
  }
}

export class DictdDict {
  #dict = new DictZip();
  #idx = new DictdIndex();
  loadDict(file, inflate) {
    this.#dict.inflate = inflate;
    return this.#dict.load(file);
  }
  async #readWord(i) {
    const word = this.#idx.getWord(i);
    const offset = this.#idx.offsets[i];
    const size = this.#idx.sizes[i];
    return { word, data: ["m", this.#dict.read(offset, size)] };
  }
  #readWords(arr) {
    return Promise.all(arr.map(this.#readWord.bind(this)));
  }
  lookup(query) {
    return this.#readWords(this.#idx.lookup(query));
  }
}

class StarDictIndex extends Index {
  isSyn;
  #arr;
  getWord(i) {
    const word = this.words[i];
    if (!word) return;
    return decode(this.#arr.subarray(word[0], word[1]));
  }
  async load(file) {
    const { isSyn } = this;
    const buf = await file.arrayBuffer();
    const arr = new Uint8Array(buf);
    this.#arr = arr;
    const view = new DataView(buf);
    const words = [];
    const offsets = [];
    const sizes = [];
    for (let i = 0; i < arr.length; ) {
      const newI = arr.subarray(0, i + 256).indexOf(0, i);
      if (newI < 0) throw new Error("Word too big");
      words.push([i, newI]);
      offsets.push(view.getUint32(newI + 1));
      if (isSyn) i = newI + 5;
      else {
        sizes.push(view.getUint32(newI + 5));
        i = newI + 9;
      }
    }
    this.words = words;
    this.offsets = offsets;
    this.sizes = sizes;
  }
}

export class StarDict {
  #dict = new DictZip();
  #idx = new StarDictIndex();
  #syn = Object.assign(new StarDictIndex(), { isSyn: true });
  async loadIfo(file) {
    const str = decode(await file.arrayBuffer());
    this.ifo = Object.fromEntries(
      str
        .split("\n")
        .map((line) => {
          const sep = line.indexOf("=");
          if (sep < 0) return;
          return [line.slice(0, sep), line.slice(sep + 1)];
        })
        .filter((x) => x),
    );
  }
  loadDict(file, inflate) {
    this.#dict.inflate = inflate;
    return this.#dict.load(file);
  }
  loadIdx(file) {
    return this.#idx.load(file);
  }
  loadSyn(file) {
    if (file) return this.#syn.load(file);
  }
  async #readWord(i) {
    const word = this.#idx.getWord(i);
    const offset = this.#idx.offsets[i];
    const size = this.#idx.sizes[i];
    const data = await this.#dict.read(offset, size);
    const seq = this.ifo.sametypesequence;
    if (!seq) throw new Error("TODO");
    if (seq.length === 1) return { word, data: [[seq[0], data]] };
    throw new Error("TODO");
  }
  #readWords(arr) {
    return Promise.all(arr.map(this.#readWord.bind(this)));
  }
  lookup(query) {
    return this.#readWords(this.#idx.lookup(query));
  }
  synonyms(query) {
    return this.#readWords(this.#syn.lookup(query).map((i) => this.#syn.offsets[i]));
  }
}
