# -*- coding: utf-8 -*-
r"""Disseca uma captura do hook opcode a opcode, sem saber nada sobre o jogo.

    python tools/dissecar.py <log>                 resumo de todos os opcodes
    python tools/dissecar.py <log> 09 02           so esse opcode, campo a campo
    python tools/dissecar.py <log> --gravar        um ficheiro por opcode
    python tools/dissecar.py                       a captura mais recente

A DIFERENCA PARA analisar_captura.py: aquele sabe o que procurar -- dano, EXP, mobs, itens --
e so ve o que ja foi decifrado. Este nao sabe nada. Pega em cada opcode, poe todos os
pacotes dele lado a lado e diz o que consegue observar de cada posicao de byte:

    - e sempre igual?           -> constante, provavelmente parte do formato
    - varia pouco?              -> flag, contador pequeno, enumeracao
    - varia muito?              -> id, posicao, valor
    - parece float valido?      -> coordenada, velocidade, multiplicador
    - e texto legivel?          -> nome, mensagem
    - cresce sempre?            -> numero de sequencia, relogio
    - bate com uma tabela?      -> item_id, npc_id, id de string

E ISSO QUE PERMITE DECIFRAR UM OPCODE NOVO. Foi assim, a mao, que se decifrou o 04 01, o
05 02 e o 08 02 -- por comparacao de pacotes lado a lado. Isto automatiza essa parte.

Os avisos que a ferramenta da valem tanto como o que ela afirma: um campo marcado "sempre
igual em 3 pacotes" nao e uma constante do protocolo, e so tres amostras.
"""
import collections
import glob
import math
import os
import re
import struct
import sys

RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(RAIZ, "tools", "analise"))
sys.path.insert(0, os.path.join(RAIZ, "server"))
from ler_hook import pacotes                                        # noqa: E402
import data_files as D                                              # noqa: E402

CR = chr(13) + chr(10)
MIN_STRING = 3          # letras seguidas para chamar texto a alguma coisa
AMOSTRAS_MAX = 4000     # por opcode; acima disto o perfil ja nao muda


def carregar_ids():
    """Conjuntos de ids conhecidos, para reconhecer campos que apontam para tabelas."""
    D.carregar()
    saida = {}
    for tabela, coluna, nome in (("Item", "item_id", "item"),
                                 ("Mob", "mob_id", "mob"),
                                 ("Npc", "npc_id", "npc"),
                                 ("Skill", "skill_id", "skill")):
        try:
            saida[nome] = {int(r[coluna]) for r in D.tabela(tabela) if r.get(coluna)}
        except Exception:                                           # noqa: BLE001
            saida[nome] = set()
    return saida


def recolher(caminho, filtro=None):
    """{(direcao, opcode): [corpos]}"""
    saida = collections.defaultdict(list)
    for direcao in ("RECV", "SEND"):
        for _t, op, c in pacotes(caminho, direcao=direcao):
            k = op.hex(" ")
            if filtro and k != filtro:
                continue
            if len(saida[(direcao, k)]) < AMOSTRAS_MAX:
                saida[(direcao, k)].append(c)
    return saida


def perfil_campo(amostras, off, ids):
    """O que se consegue dizer da posicao `off` olhando todos os pacotes."""
    b = [a[off] for a in amostras if off < len(a)]
    if not b:
        return None
    distintos = len(set(b))
    nota = []

    if distintos == 1:
        return ("constante", "0x%02x" % b[0], "")

    # inteiros nos quatro formatos
    leituras = {}
    for fmt, tam, rot in (("<H", 2, "u16 LE"), (">H", 2, "u16 BE"),
                          ("<I", 4, "u32 LE"), (">I", 4, "u32 BE")):
        vals = []
        for a in amostras:
            if off + tam <= len(a):
                vals.append(struct.unpack_from(fmt, a, off)[0])
        if vals:
            leituras[rot] = vals

    # float plausivel: nem NaN, nem absurdo, e com alguma variedade
    fl = []
    for a in amostras:
        if off + 4 <= len(a):
            v = struct.unpack_from("<f", a, off)[0]
            fl.append(v)
    if fl:
        bons = [v for v in fl if v == v and abs(v) < 1e7 and (v == 0 or abs(v) > 1e-4)]
        if len(bons) >= max(3, len(fl) * 0.9) and len({round(v, 2) for v in bons}) > 2:
            lo, hi = min(bons), max(bons)
            if 100 <= lo and hi <= 40000:
                nota.append("coordenada?")
            elif 0 < lo and hi < 20:
                nota.append("fator/velocidade?")
            return ("float LE", "%.3f a %.3f" % (lo, hi), " ".join(nota))

    # aponta para alguma tabela?
    for rot, vals in leituras.items():
        if not vals:
            continue
        for nome, conjunto in ids.items():
            if not conjunto:
                continue
            acertos = sum(1 for v in vals if v in conjunto)
            if acertos >= max(3, len(vals) * 0.8):
                return (rot, "%d valores" % len(set(vals)), "id de %s" % nome)

    # sempre a crescer? sequencia ou relogio
    for rot in ("u32 BE", "u32 LE"):
        vals = leituras.get(rot)
        if vals and len(vals) > 5:
            descidas = sum(1 for i in range(1, len(vals)) if vals[i] < vals[i-1])
            if descidas <= len(vals) * 0.05 and len(set(vals)) > len(vals) * 0.5:
                return (rot, "%d..%d" % (min(vals), max(vals)), "cresce -- sequencia?")

    if distintos <= 8:
        comuns = collections.Counter(b).most_common(4)
        return ("u8", ", ".join("0x%02x x%d" % kv for kv in comuns), "poucos valores -- flag?")
    return ("u8", "%d valores distintos" % distintos, "")


def strings_de(amostras):
    """Trechos de texto legivel e onde aparecem."""
    achados = collections.Counter()
    for a in amostras[:200]:
        for m in re.finditer(rb"[\x20-\x7e]{%d,}" % MIN_STRING, a):
            achados[(m.start(), m.group().decode("latin-1"))] += 1
    return achados


def dissecar(direcao, opcode, amostras, ids, out):
    tam = collections.Counter(len(a) for a in amostras)
    out("")
    out("=" * 74)
    out("%s  %s   %d pacotes   tamanhos: %s"
        % (direcao, opcode, len(amostras),
           ", ".join("%d (x%d)" % kv for kv in tam.most_common(3))))
    out("=" * 74)

    # so os do tamanho mais comum: misturar tamanhos baralha os offsets
    comum = tam.most_common(1)[0][0]
    iguais = [a for a in amostras if len(a) == comum]
    if len(iguais) < len(amostras):
        out("   (perfil feito sobre os %d pacotes de %d bytes)" % (len(iguais), comum))
    if len(iguais) < 3:
        out("   POUCOS PACOTES para tirar perfil -- com menos de tres nao ha o que comparar,")
        out("   e sem comparacao nao se distingue um campo de um valor. Despejo em bruto:")
        for a in iguais[:3]:
            for o in range(0, min(len(a), 256), 16):
                out("      %4d  %-47s  %s"
                    % (o, a[o:o+16].hex(" "),
                       "".join(chr(x) if 32 <= x < 127 else "." for x in a[o:o+16])))
            if len(a) > 256:
                naozero = [o for o in range(256, len(a)) if a[o]]
                out("      ... mais %d bytes; nao-zero em: %s"
                    % (len(a) - 256,
                       (str(naozero[:16]) + ("..." if len(naozero) > 16 else ""))
                       if naozero else "nenhum (tudo zeros)"))
            out("")
        return

    out("   %-6s %-10s %-24s %s" % ("offset", "tipo", "valores", "nota"))
    off = 0
    while off < comum:
        p = perfil_campo(iguais, off, ids)
        if p is None:
            break
        tipo, valores, nota = p
        # constantes seguidas juntam-se numa linha so
        if tipo == "constante":
            fim = off
            while fim + 1 < comum:
                q = perfil_campo(iguais, fim + 1, ids)
                if not q or q[0] != "constante":
                    break
                fim += 1
            crus = " ".join("%02x" % iguais[0][i] for i in range(off, min(fim + 1, off + 12)))
            if fim > off:
                out("   %-6s %-10s %-24s %s" % ("%d-%d" % (off, fim), "constante", crus, ""))
            else:
                out("   %-6d %-10s %-24s %s" % (off, "constante", crus, ""))
            off = fim + 1
            continue
        out("   %-6d %-10s %-24s %s" % (off, tipo, valores[:24], nota))
        # AVANCA UM BYTE, e nao o tamanho do tipo.
        #
        # Consumir 4 bytes ao ver um u32 parece limpo e engana: se o campo comecar uma casa
        # a frente, o resto da leitura fica desalinhado e os campos seguintes desaparecem.
        # Foi o que aconteceu no 04 03 -- o 0x01 fixo do offset 6, que ate ja se conhecia,
        # nao chegou a aparecer porque ficou dentro do u32 lido em 4. Byte a byte mostra a
        # mesma coisa vista de varias formas, e cabe a quem le escolher; nao esconde nada.
        off += 1

    txt = strings_de(iguais)
    if txt:
        out("")
        out("   TEXTO ENCONTRADO:")
        for (o, s), n in txt.most_common(10):
            out("      offset %4d  x%-4d  %s" % (o, n, s[:52]))


def resumo(grupos, out):
    out("RESUMO  (o que passou no fio)")
    out("   %-6s %-8s %8s %10s   %s" % ("dir", "opcode", "pacotes", "tamanhos", "texto?"))
    for (direcao, k), amostras in sorted(grupos.items(),
                                         key=lambda kv: -len(kv[1])):
        tam = collections.Counter(len(a) for a in amostras)
        tt = ", ".join(str(t) for t, _ in tam.most_common(2))
        tem_txt = "sim" if strings_de(amostras[:40]) else ""
        out("   %-6s %-8s %8d %10s   %s" % (direcao, k, len(amostras), tt, tem_txt))


def main() -> int:
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    gravar = "--gravar" in sys.argv
    caminho, filtro = None, None
    if args and os.path.exists(args[0]):
        caminho, args = args[0], args[1:]
    if len(args) >= 2:
        filtro = "%s %s" % (args[0].lower(), args[1].lower())
    elif len(args) == 1:
        filtro = args[0].lower()
    if caminho is None:
        cands = sorted(glob.glob(os.path.join(os.path.expanduser("~"), "Desktop",
                                              "CapturaKaros", "*.log")),
                       key=os.path.getmtime, reverse=True)
        cands += glob.glob("C:" + os.sep + "RoshHook" + os.sep + "traffic.log")
        if not cands:
            print("nenhuma captura encontrada")
            return 1
        caminho = cands[0]

    linhas = []

    def out(s=""):
        linhas.append(s)
        print(s)

    out("%s   (%.1f MB)%s"
        % (os.path.basename(caminho), os.path.getsize(caminho) / 1e6,
           ("   filtro: " + filtro) if filtro else ""))
    out("")
    ids = carregar_ids()
    grupos = recolher(caminho, filtro)
    grupos = {k: v for k, v in grupos.items() if v}
    if not grupos:
        out("nenhum pacote%s" % (" com esse opcode" if filtro else ""))
        return 1

    if filtro:
        for (direcao, k), amostras in sorted(grupos.items()):
            dissecar(direcao, k, amostras, ids, out)
    else:
        resumo(grupos, out)
        out("")
        out("Para dissecar um opcode:  python tools/dissecar.py <log> 09 02")

    if gravar:
        nome = os.path.splitext(os.path.basename(caminho))[0]
        pasta = os.path.join(RAIZ, "analise", nome, "opcodes")
        os.makedirs(pasta, exist_ok=True)
        n = 0
        for (direcao, k), amostras in grupos.items():
            sub = []
            dissecar(direcao, k, amostras, ids, sub.append)
            cam = os.path.join(pasta, "%s_%s.txt" % (direcao, k.replace(" ", "")))
            with open(cam, "w", encoding="latin-1", errors="replace", newline="") as f:
                f.write(CR.join(sub) + CR)
            n += 1
        out("")
        out("gravados %d ficheiros em analise/%s/opcodes/" % (n, nome))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
