"""
Decodificador do formato .lod (tabelas de dados/strings do client Karos/Rosh Online).

Cracked via decompilacao real (Ghidra) do AMo.exe em 22/08, decompiler session:
  - FUN_0072104a  -> le o cabecalho de 93 bytes e monta o estado da cifra
  - FUN_007210d7  -> loop de decifragem byte-a-byte (cifra de fluxo auto-chaveada,
                      com tabela de substituicao de 31 bytes embutida no proprio arquivo)
  - FUN_004511d5  -> consumidor de alto nivel usado por String.lod: le 4 bytes (contagem
                      de registros, int32 LE) e depois registros fixos de 0x404=1028 bytes
                      (4 bytes de ID + 1024 bytes de texto terminado em NUL)

Confirmado bit-a-bit contra a disassembly crua (nao so o pseudo-C do decompiler, que tinha
convencoes de chamada erradas por causa do -noanalysis). Validado batendo:
  - contagem de registros decifrada (3212) contra o tamanho real do arquivo
    (4 + 3212*1028 == tamanho decifrado, a menos de ~45 bytes de sobra no fim)
  - texto legivel em portugues, incluindo dois nomes de servidor ja confirmados
    empiricamente: id 1001 = "Azmara-Blackforest" (server_id 42), id 1028 = "Bent-Gerot"
    (server_id 28). ATENCAO: a relacao id-1000==server_id NAO é geral (bate pro 28, nao
    bate pro 42) -- ainda falta achar a tabela real de server_id -> string_id (candidata:
    dentro de IE_Maplist.lod, que usa um parser diferente e ainda nao foi craqueado).

Este modulo so decifra o STREAM de bytes (equivalente a decrypt_ten() do ten_crypto.py,
so que pra .lod). O parsing por-registro de String.lod tem uma funcao dedicada abaixo.
Outros tipos de .lod (Maplist, SkillData, etc.) usam esse MESMO decrypt_lod_stream(), mas
layouts de registro DIFERENTES -- ainda nao mapeados.
"""
import struct


XOR_ARENA = 0x0D
"""Chave da camada extra que o client do servidor Arena poe por cima dos .lod localizados.

COMO SE ACHOU (30/08). Os ficheiros SEM sufixo de idioma (SkillTree_*.lod) abriam com o
nosso decifrador; os `_en` e `_sa` nao -- 202 ficheiros. Varreu-se a constante 0x4F nos 256
valores, e depois o passo da tabela, o inicio do fluxo e a chave inicial em 400 x 256
combinacoes. Nada. Nao era variacao dos parametros.

O QUE ABRIU A PORTA foi um ficheiro de TRES BYTES. O `IE_CharFiltering.lod` do client
oficial nao esta sequer cifrado -- e `30 0d 0a`, isto e "0" e um fim de linha, em texto
limpo. O do Arena tem tambem tres bytes: `bd 80 87`. Com o claro e o cifrado do mesmo
ficheiro na mao, o XOR sai de imediato, e e o MESMO nos tres bytes:

    0xbd ^ 0x30 = 0x8d      0x80 ^ 0x0d = 0x8d      0x87 ^ 0x0a = 0x8d

Aplicando um XOR de um byte ao ficheiro inteiro, o `.lod` normal aparece por baixo e o nosso
decifrador de sempre trata do resto. A chave util e 0x0D -- o 0x8D tambem passa a assinatura
nalguns, por causa do bit 7, mas o 0x0D e o que abre os 202.

O PROVEITO E O CONTEUDO NOVO, nao a cifra: o client deles e de 29/06/2026 e o nosso de 2019.
Trazem 8851 niveis de skill contra os nossos 4623, e 10 a 13 skills a mais em cada arvore
avancada. Ver a seccao 50 dos achados.
"""


def decrypt_lod_stream(data: bytes, pular_zero: bool = True, xor: int = 0) -> bytes:
    """Decifra um .lod.

    ATENCAO ao parametro `pular_zero`. Decompilando o leitor do client (FUN_007210d7 no
    AMo.exe) apareceu um flag no objeto leitor, em [0x24], que liga ou desliga o consumo de um
    byte extra depois de cada zero decodificado:

        if ((saida == 0) && (leitor[0x24] != 0))  le mais um byte;

    Ate 23/08 a gente pulava SEMPRE. Isso funciona nas tabelas de texto (poucos zeros), mas
    destroi as tabelas binarias grandes -- itemBase, itemAttr, skillData, skillLevel e as
    SkillTree de nivel 2 e 3 -- que sao cheias de zeros: a partir do primeiro, tudo desalinha.
    Elas pareciam ter uma "segunda camada" de compressao. Nao tinham; era o modo errado.

    Regra pratica: se o resultado sair com entropia alta e quase sem zeros, tente o outro modo.
    """
    """Decifra o stream bruto de um arquivo .lod inteiro (sem parsear registros)."""
    if xor:
        data = bytes(b ^ (xor & 0xFF) for b in data)   # camada extra -- ver XOR_ARENA
    pos = 0
    header = data[pos:pos + 0x5d]
    if len(header) < 0x5d:
        raise ValueError("arquivo menor que o cabecalho esperado (0x5d bytes)")
    pos += 0x5d

    table = bytes(header[1 + 3 * i] for i in range(0x1f))  # 31 bytes, header[1], header[4], ...
    xor_mask = header[0x5c]  # ultimo byte do cabecalho (overflow de 1 byte no C original)

    skip_raw = data[pos]; pos += 1
    skip_count = skip_raw ^ xor_mask
    pos += skip_count

    key_init = data[pos]; pos += 1
    key_byte = (key_init + 0x4F) & 0xFF

    out = bytearray()
    counter = 0
    n = len(data)
    while pos < n:
        raw = data[pos]; pos += 1
        b1 = raw ^ ((table[counter % 0x1f] + key_byte) & 0xFF)
        dec = (b1 - key_byte) & 0xFF
        key_byte = (table[counter % 0x1f] + b1 + 0x4F) & 0xFF
        out.append(dec)
        if dec == 0 and pular_zero and pos < n:
            pos += 1  # byte delimitador extra (convencao usada quando ha campos terminados em NUL)
        counter += 1
    return bytes(out)


def _extract_header(data: bytes):
    """Extrai (header_prefix_bytes, table, key_byte_inicial) de um .lod sem decifrar o resto.
    header_prefix_bytes = tudo que fica IGUAL no arquivo re-cifrado (cabecalho + skip + byte de
    chave inicial) -- so o corpo depois disso e regravado."""
    pos = 0
    header = data[pos:pos + 0x5d]
    if len(header) < 0x5d:
        raise ValueError("arquivo menor que o cabecalho esperado (0x5d bytes)")
    pos += 0x5d

    table = bytes(header[1 + 3 * i] for i in range(0x1f))
    xor_mask = header[0x5c]

    skip_raw = data[pos]; pos += 1
    skip_count = skip_raw ^ xor_mask
    pos += skip_count

    key_init = data[pos]; pos += 1
    key_byte = (key_init + 0x4F) & 0xFF

    header_prefix = data[:pos]
    return header_prefix, table, key_byte


def encrypt_lod_body(plaintext: bytes, table: bytes, key_byte: int,
                     pular_zero: bool = True) -> bytes:
    """Inverso exato de decrypt_lod_stream (a partir do estado inicial da cifra: mesma tabela de
    31 bytes + key_byte inicial). Dado o algoritmo de decifra:
        b1  = raw ^ (table[i%31] + key)
        dec = (b1 - key) & 0xff
        key'= (table[i%31] + b1 + 0x4f) & 0xff
    a inversao pra cifrar um byte 'dec' conhecido e:
        b1  = (dec + key) & 0xff
        raw = b1 ^ (table[i%31] + key) & 0xff
        key'= (table[i%31] + b1 + 0x4f) & 0xff   -- mesma formula, key' so depende de b1
    Se dec==0, o decifrador consome 1 byte extra (delimitador) que e so descartado -- entao
    aqui so precisamos escrever mais 1 byte qualquer (0x00) nesse caso, sem afetar o estado da
    chave.

    `pular_zero` TEM DE SER O MESMO com que se decifrou. Faltava aqui ate 01/09: cifrava-se
    sempre com delimitador, mesmo o corpo que se tinha lido em modo False. Nas tabelas de
    texto nao se nota, mas o itemAttr sao 4,5 MB de zeros: o ficheiro gravado saiu com
    9.926.616 bytes em vez de 5,4 MB e nao voltava a abrir."""
    out = bytearray()
    counter = 0
    key = key_byte
    for dec in plaintext:
        b1 = (dec + key) & 0xFF
        raw = b1 ^ ((table[counter % 0x1f] + key) & 0xFF)
        out.append(raw)
        key = (table[counter % 0x1f] + b1 + 0x4F) & 0xFF
        if dec == 0 and pular_zero:
            out.append(0)  # delimitador -- valor arbitrario, decifrador so descarta
        counter += 1
    return bytes(out)


def patch_lod_file(path: str, out_path: str, patch_fn) -> None:
    """Le um .lod, decifra o corpo inteiro, aplica `patch_fn(bytearray_do_corpo_decifrado)` (que
    modifica o buffer IN-PLACE, sem mudar o tamanho total), recifra com a MESMA tabela/chave
    inicial do arquivo original (preservando o cabecalho intacto) e grava em out_path."""
    with open(path, "rb") as f:
        data = f.read()
    header_prefix, table, key_byte = _extract_header(data)
    plaintext = bytearray(decrypt_lod_stream(data))
    patch_fn(plaintext)
    new_body = encrypt_lod_body(bytes(plaintext), table, key_byte)
    with open(out_path, "wb") as f:
        f.write(header_prefix)
        f.write(new_body)


def patch_string_lod_text(path: str, out_path: str, replacements: dict[int, str]) -> None:
    """Troca o texto de registros especificos de String.lod (por ID), mantendo o tamanho do
    registro (1024 bytes de texto, resto preenchido com NUL) -- new text tem que caber."""
    recsize = 0x404

    def _patch(plaintext: bytearray) -> None:
        count = struct.unpack("<I", plaintext[:4])[0]
        for i in range(count):
            off = 4 + i * recsize
            rid = struct.unpack("<I", plaintext[off:off + 4])[0]
            if rid in replacements:
                new_text = replacements[rid].encode("cp1252")
                if len(new_text) >= 1024:
                    raise ValueError(f"texto novo para id {rid} nao cabe em 1024 bytes")
                text_off = off + 4
                plaintext[text_off:text_off + 1024] = new_text + b"\x00" * (1024 - len(new_text))

    patch_lod_file(path, out_path, _patch)


def parse_string_lod(decoded: bytes) -> dict[int, str]:
    """Parseia o layout especifico de String.lod: 4 bytes de contagem + registros de
    0x404 bytes (4 bytes de ID little-endian + 1024 bytes de texto terminado em NUL,
    codificado em cp1252)."""
    count = struct.unpack("<I", decoded[:4])[0]
    recsize = 0x404
    out = {}
    for i in range(count):
        off = 4 + i * recsize
        rec = decoded[off:off + recsize]
        if len(rec) < recsize:
            break
        rid = struct.unpack("<I", rec[:4])[0]
        text = rec[4:]
        nul = text.find(b"\x00")
        s = text[:nul if nul >= 0 else len(text)]
        out[rid] = s.decode("cp1252", errors="replace")
    return out


def load_string_lod(path: str) -> dict[int, str]:
    with open(path, "rb") as f:
        data = f.read()
    return parse_string_lod(decrypt_lod_stream(data))


if __name__ == "__main__":
    import sys
    path = sys.argv[1] if len(sys.argv) > 1 else r"C:\Games\Karos\ResUnpak\Interface\IE_Lod\string.lod_pt"
    table = load_string_lod(path)
    print(f"{len(table)} strings carregadas de {path}")
    for rid in sorted(table)[:50]:
        print(rid, table[rid])
