# -*- coding: utf-8 -*-
r"""Mede a EXP QUE CADA ESPECIE DA, nas capturas do oficial.

    python tools/medir_exp.py  ->  server/Data/Monsters/MobExp.ini

COMO SE MEDE. O 08 01 traz a EXP do jogador em [69:77] (LE u64) e o nivel em [90:94] (BE
u32). O 08 06 com hp=0 numa entidade de monstro e a morte dele. Emparelha-se cada SUBIDA de
EXP com as mortes que houve desde o pacote anterior.

SO CONTA O CASO DE UMA MORTE. O 08 01 e periodico, nao vem por abate: entre dois pacotes
podem ter morrido tres bichos, e a subida e a soma dos tres. Foi exatamente assim que se
errou em 23/08 -- vi 24 -> 96 -> 168, li "72 por aranha" e eram TRES aranhas de 24. Descartar
os saltos com mais de uma morte custa amostra e e a unica forma de o numero significar o que
diz.

VALIDACAO INDEPENDENTE: a coluna exp_base do Mob.ini ja trazia 3 especies de outra fonte, e a
Aranha pequena la diz 24 -- que e exatamente o que esta medicao devolve.

O QUE ISTO NAO MEDE: a penalizacao por diferenca de nivel. Ver tools/medir_exp_nivel.py.
Aqui grava-se o valor visto com o jogador ABAIXO do monstro, que e onde a razao medida da
1,00 e portanto onde o valor e o base.
"""
import collections
import os
import struct
import sys

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "analise"))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "server"))
import metricas as M                                                # noqa: E402

# OBSERVADO PELO JOGADOR, no proprio jogo oficial, com o personagem AO NIVEL do bicho (ou
# abaixo), que e onde nao ha penalizacao. Entram aqui porque valem mais do que a medicao
# automatica: ele viu o numero na tela, sem emparelhamento nem filtro pelo meio.
#
# 29/08 -- "personagem LV1 ganha 24 de exp na aranha que ela sao lvl 1, Filhote de urso
# feroz que e LV5 da 92 de exp, o Javali de sabre e lvl 7 e da 131 de exp".
#
# O primeiro confirma a medicao automatica (que tambem deu 24) e a coluna exp_base do
# Mob.ini (que ja dizia 24) -- tres fontes independentes no mesmo numero.
OBSERVADO = {
    1001801: 24,      # Aranha pequena, nivel 1
    1001005: 92,      # Filhote de urso feroz, nivel 5  (o Mob.ini dizia 88)
    1004807: 131,     # Javali-de-sabre, nivel 7
    1004509: 176,     # Bico de alumina, nivel 9 -- ver a nota abaixo
    1004908: 152,     # Javali louco, nivel 8   -- captura de 30/08 22:49, ver abaixo
    1005002: 31,      # Urso coruja, nivel 2    -- idem
    1000814: 281,     # Urso furioso, nivel 14
    1002614: 239,     # Zombies, nivel 14
    1005110: 223,     # Lagarto do pantano, nivel 13
    1005211: 239,     # Velocista do pantano, nivel 14
    1005717: 292,     # Tirano sanguinario, nivel 17
    1006703: 55,      # Cao da estepe, nivel 3
}
#
# A CAPTURA DE REFERENCIA DE BERNEO -- karos_20260830_2249.log.
#
# O jogador matou UM de cada especie do mapa, sozinho, e avisou do evento 3x. Depois de
# aplicar o desconto e de contar so as mortes com o id dele em [23:27], ela devolve doze
# especies, e CINCO delas batem ao certo com valores que ja tinhamos por outro caminho:
#
#     Aranha pequena 24    Urso coruja 31    Filhote 92    Javali louco 152    Bico 176
#
# Cinco em cinco valida o metodo e o factor. Os outros sete entram por isso, e ganham aos
# valores que a mediana automatica dava -- essa vinha de capturas onde havia eventos que
# ninguem declarou (o Velocista saia a 684, que e 2,9 vezes o medido aqui).
#
# O JAVALI-DE-SABRE FICA NOS 131 e nao nos 125 que esta captura da. Os 131 vieram do jogador
# a ler o numero no ecra, sem emparelhamento pelo meio, e 5 por cento e menos do que a
# incerteza de uma amostra so.
#
# O URSO FURIOSO (309) so apareceu quando a pasta C:\RoshHook entrou nas fontes, em 30/08 --
# eram 180 MB de captura do oficial que esta ferramenta nunca tinha lido. Fica aqui, e nao
# na medicao automatica, pela mesma razao dos outros dois: o valor e UNICO no balde e
# repete-se em tres niveis de jogador diferentes (dif 12, 11 e 10), sempre 309,0 exactos.
# Isso e mais forte do que qualquer mediana tirada de dados com evento por dentro.
#
# OS DOIS DE 30/08 vieram da captura das 22:49, em que o jogador matou um de cada especie
# com um personagem de nivel 1. Essa sessao tinha EXP a TRIPLICAR, e o factor confirmou-se
# por dentro: o Cao da estepe mediu 165 e o valor ja estabelecido por outra sessao e 55 --
# razao 1,00 depois de dividir por 3.
#
# Entram aqui, e nao pela medicao automatica, porque o balde deles tem valor UNICO (o Javali
# louco deu 152 nas duas amostras) enquanto o resto da captura esta contaminado -- ver a
# nota sobre eventos nao declarados, mais abaixo.
#
# O BICO DE ALUMINA (1004509) veio por outra via: nao foi o jogador a ler o numero, foi
# medido na captura dele de 29/08 as 14:10, emparelhando a subida de EXP com a morte que a
# causou. Duas amostras limpas, as duas com UMA so morte entre dois 08 01, as duas +176.
#
# Ele reportou 178 e a medicao da 176. Nao sei explicar os 2 de diferenca -- pode ser
# arredondamento na tela, pode ser um bonus pequeno que nao isolei. Fica o medido, que e o
# que se pode defender, e a diferenca fica escrita aqui em vez de desaparecer.
#
# ATENCAO: 1004509 e o Bico de alumina; o 1004908, que eu tinha como sendo ele, e o Javali
# louco. Os dois aparecem na mesma zona e trocar um pelo outro poe a EXP no bicho errado.

# NIVEL_PELO_LIMIAR -- corrigido em 30/08.
#
# Esta ferramenta usava `[90:94]` do 08 01 como nivel do jogador, na crenca de que ele
# guardava "nivel menos um". NAO GUARDA: na captura das 22:49 esse campo alterna entre 0 e 1
# com a MESMA experiencia e o MESMO nivel, ou seja e uma bandeira.
#
# O nivel verdadeiro le-se do LIMIAR em `[77:85]`, que segue o Level.ini degrau a degrau --
# 48, 216, 520, 972, 1577, 2343, 3274 nos sete primeiros. Basta procurar o limiar na tabela.
#
# O estrago que isto fazia: o par so era aceite com a bandeira igual dos dois lados, portanto
# rejeitavam-se mortes dentro do mesmo nivel e aceitavam-se mortes que ATRAVESSAVAM um nivel
# -- exactamente ao contrario. Foi uma das fontes do ruido que punha 2x e 3x do mesmo valor
# no mesmo balde.

OP_GAGE = bytes([8, 1])
OP_HP = bytes([8, 6])
MINIMO_AMOSTRAS = 3


def capturas():
    base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "captures")
    alvos = [os.path.join(base, f) for f in sorted(os.listdir(base))
             if f.startswith("oficial") and f.endswith(".log")]
    d2 = os.path.join(os.environ.get("USERPROFILE", ""), "Desktop", "CapturaKaros")
    if os.path.isdir(d2):
        alvos += [os.path.join(d2, f) for f in sorted(os.listdir(d2))
                  if f.endswith(".log") and "2209" not in f]
    # A TERCEIRA PASTA, esquecida ate 30/08: sao 180 MB de captura do oficial, mais do que
    # as outras duas juntas. A mesma omissao que o extractor de trilhas tinha.
    #
    # DUAS EXCLUSOES OBRIGATORIAS: `nosso` e `fake` sao capturas do NOSSO servidor. Medir
    # EXP nelas seria ler o nosso proprio numero e grava-lo como se fosse do oficial -- um
    # circulo que se fecha sem dar sinal nenhum de que esta errado.
    d3 = "C:" + os.sep + "RoshHook"
    if os.path.isdir(d3):
        alvos += [os.path.join(d3, f) for f in sorted(os.listdir(d3))
                  if f.startswith("traffic") and f.endswith(".log")
                  and "nosso" not in f and "fake" not in f]
    return alvos


EVENTO = {
    "karos_20260830": 3.0,
}
"""Capturas feitas com evento de EXP a mais. O valor divide-se por este factor.

O jogador avisou da de 30/08 as 22:49 -- *"ta tendo exp 3x entao tem descontar"*. NAO SE
ACREDITA NELE POR DECRETO: a captura mede o Cao da estepe em 165 e o valor ja estabelecido
por outra sessao e 55. Razao 1,00 depois de dividir por 3, ou seja o factor confirma-se por
dentro dos proprios dados.

Sem esta tabela uma sessao de evento entra na mediana e puxa tudo para cima sem deixar
rasto -- e o tipo de erro que so aparece meses depois, quando alguem estranha um numero.
"""


def multiplicador(caminho):
    """Factor de evento da captura. A chave e um PREFIXO, porque o evento e do dia.

    O jogador em 30/08: *"se log da aguia foi hj tinha evento, se foi ontem tava sem"*. As
    capturas trazem a data no nome, portanto o dia inteiro apanha-se com um prefixo em vez
    de listar ficheiro a ficheiro e esquecer um.
    """
    nome = os.path.basename(caminho)
    for prefixo, f in EVENTO.items():
        if nome.startswith(prefixo):
            return f
    return 1.0


def main() -> int:
    import data_files as D
    D.carregar()
    niveis, nomes = {}, {}
    for r in D.tabela("Mob"):
        if r.get("mob_id"):
            nomes[r["mob_id"]] = r.get("nome")
            try:
                niveis[r["mob_id"]] = int(r.get("nivel"))
            except (TypeError, ValueError):
                pass

    # O NIVEL SAI DO LIMIAR, e nao do campo [90:94] -- ver a nota NIVEL_PELO_LIMIAR.
    nivel_de_limiar, limiar_do_nivel = {}, {}
    for r in D.tabela("Level"):
        try:
            nv, lim = int(r["nivel"]), int(r["exp_para_subir"])
        except (TypeError, ValueError, KeyError):
            continue
        nivel_de_limiar[lim] = nv
        limiar_do_nivel[nv] = lim

    am = collections.defaultdict(list)
    for cam in capturas():
        try:
            ev = M.carregar(cam)
        except Exception:                                           # noqa: BLE001
            continue
        npc_de = ev["npc"]
        # QUEM SOU EU nesta captura: a entidade do 08 01, que e a ficha do proprio.
        eu = collections.Counter()
        for _t, op, c in M.pacotes(cam, direcao="RECV"):
            if op == OP_GAGE and len(c) >= 11:
                eu[int.from_bytes(c[7:11], "big")] += 1
        meu_id = eu.most_common(1)[0][0] if eu else 0
        eventos = []
        mortas_ja = set()
        for t, op, c in M.pacotes(cam, direcao="RECV"):
            if op == OP_GAGE and len(c) >= 94:
                exp = struct.unpack_from("<Q", c, 69)[0]
                lim = struct.unpack_from("<Q", c, 77)[0]
                if exp < 10 ** 12 and lim in nivel_de_limiar:
                    eventos.append((t, "exp", exp, nivel_de_limiar[lim]))
            elif op == OP_HP and len(c) >= 27:
                eid = int.from_bytes(c[7:11], "big")
                # SO AS MORTES QUE EU CAUSEI. O campo [23:27] diz quem a provocou, e foi
                # decifrado em 30/08. Sem ele contava-se qualquer morte a vista, incluindo
                # as de outros jogadores: na captura de 27/08 sao 55 mortes alheias e 34
                # sem atacante contra 174 minhas. A subida de EXP era emparelhada com a
                # morte errada, e daí saiam valores que pareciam multiplos ao acaso.
                #
                # O jogador apanhou isto de fora: *"nao matei nenhuma aguia"*, e a aguia
                # estava na tabela.
                if (int.from_bytes(c[15:19], "big") == 0
                        and int.from_bytes(c[23:27], "big") == meu_id):
                    n = npc_de.get(eid, 0)
                    # DESDUPLICA POR ENTIDADE. O oficial repete o 08 06 com hp=0 varias
                    # vezes na mesma morte -- vi quatro seguidas no mesmo milissegundo. Sem
                    # isto uma morte conta como quatro e a divisao pelo numero de mortes
                    # devolve um quarto do valor.
                    if n > M.MOB_MINIMO and eid not in mortas_ja:
                        mortas_ja.add(eid)
                        eventos.append((t, "morte", n, 0))
        fator = multiplicador(cam)
        eventos.sort(key=lambda x: x[0])
        exp_ant = lvl_ant = None
        mortes = []
        for t, k, a, b in eventos:
            if k == "morte":
                mortes.append(a)
                continue
            # VARIAS MORTES CONTAM se forem TODAS DA MESMA ESPECIE -- divide-se pelo
            # numero. Exigir uma so descartava quase tudo numa sessao de caca rapida.
            #
            # A SUBIDA DE NIVEL TAMBEM CONTA. Antes o par era rejeitado quando o nivel
            # mudava, e e justamente ai que estao as mortes maiores -- a do Zombies desta
            # captura fez subir de 4 para 5 e por isso nao existia em tabela nenhuma. O
            # ganho e o que faltava para acabar o nivel mais o que ja entrou no seguinte.
            if exp_ant is not None and mortes and len(set(mortes)) == 1:
                if b == lvl_ant and a > exp_ant:
                    ganho = a - exp_ant
                elif b == lvl_ant + 1:
                    ganho = (limiar_do_nivel.get(lvl_ant, 0) - exp_ant) + a
                else:
                    ganho = 0
                if ganho > 0:
                    am[(mortes[0], b)].append(
                        ganho / float(len(mortes)) / fator)
            exp_ant, lvl_ant = a, b
            mortes = []

    # O BASE E O VALOR SEM PENALIZACAO, ou seja o do jogador ABAIXO do monstro. Medido: com
    # o monstro 12 ou mais niveis acima, a razao ao maior valor visto da 1,00 em todos os
    # casos com amostra; abaixo disso o numero cai (Tirano 233 -> 87 -> 29).
    linhas = []
    for npc in sorted({k[0] for k in am} | set(OBSERVADO)):
        if npc in OBSERVADO:
            linhas.append((npc, OBSERVADO[npc], 0, 0, 0, niveis.get(npc),
                           nomes.get(npc) or "", "jogador"))
            continue
        nm = niveis.get(npc)
        cands = [(lvl, v) for (n2, lvl), v in am.items()
                 if n2 == npc and len(v) >= MINIMO_AMOSTRAS
                 and (nm is None or nm - lvl >= 0)]
        if not cands:
            continue
        # O MAIOR DOS BALDES, nao a mediana de todos juntos.
        #
        # Juntar os niveis do jogador numa mediana so mistura o valor penalizado com o
        # valor base, e como ha mais caca perto do nivel do bicho a mediana desce. Deu-se
        # com o Lagarto do pantano: 154 -> 39 so por corrigir a leitura do nivel.
        #
        # O cabecalho desta ferramenta ja dizia qual e o base -- "com o monstro 12 ou mais
        # niveis acima, a razao ao maior valor visto da 1,00; abaixo disso o numero cai
        # (Tirano 233 -> 87 -> 29)". Ou seja o base e o MAIOR, e cada balde tem a sua
        # mediana. Toma-se a mediana de cada um e fica-se com a maior.
        por_balde = []
        for l, v in cands:
            sv = sorted(v)
            por_balde.append((sv[len(sv) // 2], l, len(sv)))
        melhor, nivel_do_melhor, n_do_melhor = max(por_balde)
        linhas.append((npc, melhor, n_do_melhor,
                       nivel_do_melhor, nivel_do_melhor,
                       nm, nomes.get(npc) or "", "fio"))
    print("%d especies medidas" % len(linhas))

    destino = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                           "..", "server", "Data", "Monsters", "MobExp.ini")
    nl = chr(10)
    tb = chr(9)
    with open(destino, "w", encoding="utf-8", newline="") as f:
        f.write("; MobExp -- EXP que cada especie da, MEDIDA no oficial." + nl)
        f.write("; Gerado por tools/medir_exp.py. Ver o cabecalho dessa ferramenta." + nl)
        f.write("; Quem nao estiver aqui usa a estimativa por nivel -- ver exp_de_morte." + nl)
        f.write(tb.join(["//mob_id", "exp", "amostras", "nv_jog_min",
                         "nv_jog_max", "nv_mob", "origem", "nome"]) + nl)
        for npc, exp, n, lo, hi, nm, nome, org in linhas:
            f.write(tb.join([str(npc), str(exp), str(n), str(lo), str(hi),
                             str(nm if nm is not None else ""), org, nome]) + nl)
    print("escrito em %s" % os.path.normpath(destino))
    return 0


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