# -*- coding: utf-8 -*-
"""Constroi Data/Drop/MobDrop.ini a partir das capturas do oficial.

    python tools/medir_mobdrop.py             mostra
    python tools/medir_mobdrop.py --aplicar   grava

COMO SE LIGA UM SACO AO MONSTRO QUE O LARGOU

O 06 04 nao diz de quem veio o item -- traz o dono (um jogador) e nao a fonte. A ligacao
faz-se pelo espaco e pelo tempo:

    1. seguir a posicao de cada entidade pelo 04 02 (aparicao) e pelo 03 0b (posicao)
    2. marcar cada morte: um 08 06 com HP zero em [15:19], entidade em [7:11]
    3. cada 06 04 que nasca ate JANELA segundos depois e a menos de RAIO do corpo
       conta para o npc_id daquele monstro

Os dois limites sao escolha, nao medicao, e por isso estao no topo do ficheiro em vez de
enterrados no meio. Sao apertados de proposito: e melhor perder sacos por serem ambiguos do
que atribuir a um monstro o drop do vizinho. Quando um saco cabe em DUAS mortes, e deitado
fora -- a coluna `ambiguos` conta quantos.

O QUE ISTO E E O QUE NAO E

E uma amostra pequena. Sao poucas horas de captura, num so nivel e numa so zona: da para
dizer QUE itens um monstro larga, nao para dizer com que probabilidade. Um item visto tres
vezes em vinte mortes nao mede 15% -- mede "acontece".

Por isso o peso que sai daqui e a CONTAGEM, e nao uma percentagem inventada: contagem 3 em 20
mortes escreve-se peso 3 com uma linha de vazio de peso 17. Somando 20, e a soma e o
denominador. Quando houver mais abates, os numeros sobem e a proporcao afina-se sozinha.

A coluna `abates` diz de quantas mortes veio cada linha. Uma linha com abates=2 e um palpite
com um numero ao lado; com abates=200 e uma medicao.
"""
import collections
import glob
import math
import os
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 blocos                                         # noqa: E402
import data_files as D                                              # noqa: E402

DESKTOP = os.path.join(os.path.expanduser("~"), "Desktop", "CapturaKaros")
DESTINO = os.path.join(RAIZ, "server", "Data", "Drop", "MobDrop.ini")

JANELA = 1.5          # segundos entre a morte e o saco
RAIO = 80.0           # unidades entre o corpo e o saco
ITEM_NADA = 9999999
DIAG = []
TAB = chr(9)
ASPA = chr(34)
CR = chr(13) + chr(10)


def eventos(caminho):
    for t, d, _s, dados in blocos(caminho):
        if d != "RECV":
            continue
        i, n = 0, len(dados)
        while i + 4 <= n:
            tam = int.from_bytes(dados[i:i + 4], "big")
            if tam <= 0 or tam > 65535 or i + 4 + tam > n:
                break
            c = dados[i + 4:i + 4 + tam]
            if len(c) >= 2:
                yield t, c[:2], c
            i += 4 + tam


def varrer(caminho, tabela, contagem, ambiguos, abates):
    npc_de = {}          # entidade -> npc_id
    pos_de = {}          # entidade -> (x, y)
    vivo = {}            # entidade -> True depois de aparecer, False depois de morrer
    mortes = []          # (t, npc_id, x, y)
    sacos = []           # (t, item_id, qtd, x, y)

    for t, op, c in eventos(caminho):
        if op == bytes([4, 2]) and len(c) >= 74:
            ent = struct.unpack_from("<I", c, 8)[0]
            npc_de[ent] = struct.unpack_from("<I", c, 70)[0]
            # posicao em [12:24], como o build_appear escreve -- nao em [16:28], que e o
            # offset do 05 01 e foi o que eu usei a primeira vez. Nao deu erro: deu zero
            # ligacoes, porque as posicoes ficavam todas em lixo longe de qualquer saco.
            pos_de[ent] = struct.unpack_from("<3f", c, 12)[:2]
            vivo[ent] = True
        elif op == bytes([3, 0x0B]) and len(c) >= 22:
            ent = struct.unpack_from(">I", c, 6)[0]
            if ent in npc_de:
                pos_de[ent] = struct.unpack_from("<3f", c, 10)[:2]
        elif op == bytes([8, 6]) and len(c) >= 23:
            ent = struct.unpack_from(">I", c, 7)[0]
            hp = struct.unpack_from(">I", c, 15)[0]
            if hp == 0 and vivo.get(ent) and ent in npc_de and ent in pos_de:
                vivo[ent] = False
                mortes.append((t, npc_de[ent], pos_de[ent][0], pos_de[ent][1]))
            elif hp > 0:
                vivo[ent] = True
        elif op == bytes([6, 4]) and len(c) == 187:
            p = struct.unpack_from("<3f", c, 173)
            sacos.append((t, struct.unpack_from("<I", c, 28)[0],
                          struct.unpack_from("<I", c, 42)[0], p[0], p[1]))

    for _t, npc, _x, _y in mortes:
        abates[npc] += 1

    # DIAGNOSTICO. Sem isto, "0 ligados" nao diz se a janela e curta, se o raio e curto ou
    # se as posicoes estao erradas -- e da primeira vez estavam, por um offset trocado.
    for ts, _i, _q, sx, sy in sacos[:400]:
        melhor = None
        for tm, _npc, mx, my in mortes:
            dt = ts - tm
            if -5 <= dt <= 20:
                d = math.hypot(sx - mx, sy - my)
                if melhor is None or d < melhor[1]:
                    melhor = (dt, d)
        if melhor:
            DIAG.append(melhor)

    for ts, item, qtd, sx, sy in sacos:
        candidatos = [npc for tm, npc, mx, my in mortes
                      if 0 <= ts - tm <= JANELA and math.hypot(sx - mx, sy - my) <= RAIO]
        distintos = set(candidatos)
        if not distintos:
            continue
        if len(distintos) > 1:
            ambiguos[0] += 1          # duas mortes ao lado uma da outra: nao da para saber
            continue
        npc = candidatos[0]
        contagem[(npc, item)] += 1
        # GUARDA TODAS AS QUANTIDADES, nao so o maximo. A primeira versao fazia
        # max(...), e com isso a Aranha largava sempre 36 Carat e o Cao branco sempre 120 --
        # o maior valor alguma vez visto, a cada abate. Com o Carat a ser o dinheiro do jogo,
        # isso multiplicava a economia por varias vezes, e em silencio.
        tabela.setdefault((npc, item), []).append(qtd)
    return len(mortes), len(sacos)


def main() -> int:
    aplicar = "--aplicar" in sys.argv
    D.carregar()
    itens = dict((r["item_id"], str(r.get("nome") or "")) for r in D.tabela("Item"))
    mobs = dict((r["mob_id"], str(r.get("nome") or "")) for r in D.tabela("Mob"))

    alvos = [a for a in sys.argv[1:] if not a.startswith("--")]
    if not alvos:
        alvos = sorted(glob.glob(os.path.join(DESKTOP, "*.log")))

    tabela, contagem, abates = {}, collections.Counter(), collections.Counter()
    ambiguos = [0]
    tot_m = tot_s = 0
    for alvo in alvos:
        m, s = varrer(alvo, tabela, contagem, ambiguos, abates)
        tot_m += m
        tot_s += s
        print("  %-34s %5d mortes  %5d sacos" % (os.path.basename(alvo)[:34], m, s))

    if DIAG:
        DIAG.sort(key=lambda x: x[1])
        print()
        print("DIAGNOSTICO -- para cada saco, a morte mais PROXIMA em espaco:")
        print("   distancia: p10 %.0f  mediana %.0f  p90 %.0f"
              % (DIAG[len(DIAG)//10][1], DIAG[len(DIAG)//2][1], DIAG[len(DIAG)*9//10][1]))
        dts = sorted(d[0] for d in DIAG[:len(DIAG)//4])
        print("   no quarto mais proximo, atraso morte->saco: min %.2f  mediana %.2f  max %.2f"
              % (dts[0], dts[len(dts)//2], dts[-1]))

    ligados = sum(contagem.values())
    print()
    print("mortes: %d | sacos: %d | ligados a um monstro: %d (%.0f%%) | ambiguos: %d"
          % (tot_m, tot_s, ligados, 100.0 * ligados / max(1, tot_s), ambiguos[0]))
    print("monstros com pelo menos um drop medido: %d"
          % len(set(npc for npc, _i in contagem)))
    print()

    por_mob = collections.defaultdict(list)
    for (npc, item), n in contagem.items():
        por_mob[npc].append((n, item))
    print("%-10s %-26s %-8s %-7s %s" % ("npc_id", "monstro", "abates", "itens", "top"))
    for npc in sorted(por_mob, key=lambda m: -abates[m]):
        lista = sorted(por_mob[npc], reverse=True)
        top = ", ".join("%s x%d" % (itens.get(i, str(i))[:18], n) for n, i in lista[:3])
        print("%-10d %-26s %-8d %-7d %s"
              % (npc, mobs.get(npc, "?")[:26], abates[npc], len(lista), top))

    if not aplicar:
        print()
        print("nada gravado. rode com --aplicar")
        return 0

    with open(DESTINO, "w", encoding="utf-8", newline="") as fp:
        w = fp.write
        for l in [
            "; MobDrop -- o que cada monstro larga. MEDIDO nas capturas do oficial.",
            "; Gerado por tools/medir_mobdrop.py. Nao editar a mao: corre outra vez com mais",
            "; capturas e os numeros sobem sozinhos.",
            ";",
            "; COMO SE LIGOU O SACO AO MONSTRO. O 06 04 nao diz de quem veio -- traz o dono,",
            "; que e um jogador, e nao a fonte. A ligacao e por espaco e tempo: cada saco que",
            "; nasce ate %.1f s depois de uma morte e a menos de %.0f unidades do corpo conta"
            % (JANELA, RAIO),
            "; para aquele monstro. Saco que cabe em DUAS mortes e deitado fora.",
            ";",
            "; O PESO E A CONTAGEM DE ABATES, e nao uma percentagem inventada: se um item saiu",
            "; 3 vezes em 20 mortes, fica peso 3 e abates 20. A chance da linha e peso/abates.",
            ";",
            "; CADA LINHA E SORTEADA A PARTE, e nao uma escolha unica entre todas -- o oficial",
            "; larga 2,07 sacos por abate, e o Javali larga o Carat E a costela na mesma morte.",
            "; E por isso que nao ha linha de vazio (item %d) aqui: essa pertence ao modelo do"
            % ITEM_NADA,
            "; rvrDrop, que escolhe UM entre todos. Sao duas tabelas com dois modelos.",
            ";",
            "; A QUANTIDADE E UMA FAIXA, qtd_min a qtd_max, e o servidor sorteia dentro dela.",
            "; A primeira versao guardava so o maximo visto e largava-o sempre -- a Aranha dava",
            "; 36 Carat de cada vez, o Cao branco 120. Com o Carat a ser o dinheiro do jogo,",
            "; isso multiplicava a economia varias vezes sem dar sinal nenhum.",
            ";",
            "; O sorteio dentro da faixa e UNIFORME, e isso e aproximacao: a distribuicao real",
            "; nao foi medida, so os extremos. Com poucos abates a faixa e estreita demais e",
            "; com muitos e mais fiel. Melhora sozinha a cada captura nova.",
            ";",
            "; A COLUNA abates diz de quantas mortes veio a linha, e e ela que diz se se pode",
            "; confiar. abates=2 e um palpite com um numero ao lado; abates=200 e uma medicao.",
            "; Isto sao poucas horas de captura, num nivel e numa zona: da para dizer QUE itens",
            "; um monstro larga, nao com que probabilidade.",
            ";",
            "; MEDICAO DE %s: %d mortes, %d sacos, %d ligados, %d ambiguos."
            % ("28/08", tot_m, tot_s, ligados, ambiguos[0]),
            "",
        ]:
            w(l + CR)
        w("//" + TAB.join(["mob_id", "idx", "item_id", "qtd_min", "qtd_max", "peso",
                           "abates", "fonte", "nome"]) + CR)
        for npc in sorted(por_mob):
            lista = sorted(por_mob[npc], reverse=True)
            total = abates[npc]
            visto = sum(n for n, _i in lista)
            idx = 0
            for n, item in lista:
                idx += 1
                nome = itens.get(item, "")
                qs = sorted(tabela[(npc, item)])
                w(TAB.join([str(npc), str(idx), str(item), str(qs[0]), str(qs[-1]),
                            str(n), str(total), "captura-oficial-2608",
                            (ASPA + nome + ASPA) if " " in nome else nome]) + CR)
            # SEM LINHA DE VAZIO. Ela pertence ao modelo de escolha unica do rvrDrop, e
            # os monstros nao usam esse: cada linha e sorteada a parte, com chance
            # peso/abates. O vazio sai sozinho de cada linha que nao acerta.

    D.carregar()
    t = D.tabela("MobDrop")
    print()
    print("gravado: %s (%s bytes)" % (DESTINO, "{:,}".format(os.path.getsize(DESTINO))))
    print("relido pelo servidor: %d linhas, colunas %s" % (len(t), list(t[0].keys())))
    return 0


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