"""
Servidor de login de teste pro Karos Online (client "alpha"/Rebirth, engine GameXP-Karos).

Aceita a conexao que o AMo.exe faz (depois de redirecionar o ServerList.ten), parseia o
CLoginReqPacket de verdade (formato confirmado em docs/findings.md) e loga tudo. A resposta
enviada de volta ainda e EXPERIMENTAL -- o formato exato da resposta do server real ainda nao
foi confirmado por engenharia reversa completa, so temos indicios (FUN_00651183 no client
espera portas de sub-servidor em offsets fixos do cabecalho + um novo token de sessao numa
das strings). Ajustar aqui conforme os proximos testes empiricos.

Uso:
    python login_server.py [porta]

Pra testar contra o client local: redirecionar C:\\Games\\Karos\\ServerList.ten pra
127.0.0.1:<porta> (ver docs/ten_crypto helper) e rodar:
    AMo.exe alpha gg 0
"""

from __future__ import annotations

import socket
import struct
import sys
import threading
import time as _time

_orig_print = print
def print(*args, **kwargs):  # noqa: A001 -- sombra proposital pra adicionar timestamp em tudo
    ms = int(_time.time() * 1000) % 1000
    _orig_print(f"[{_time.strftime('%H:%M:%S')}.{ms:03d}]", *args, **kwargs)

import db
import lotacao
from login_packet import (
    SIGNATURE,
    STRINGS_OFFSET,
    FrameError,
    encode_frame,
    parse_login_request,
    read_frame,
)
from channel_list_packet import ChannelEntry, ChannelListPacket

HOST = "0.0.0.0"
DEFAULT_PORT = 19999
CLOSE_IMMEDIATELY = True  # testando hipotese: servidor real manda e fecha na hora, sem esperar


def build_handshake_frame() -> bytes:
    """
    Frame de handshake/challenge observado na captura real (docs/../captures/real_server_reject_01.log):
    servidor manda isso ANTES/junto com a resposta de login; o client so ecoa de volta com o byte
    de sequencia incrementado (nao valida o conteudo do "desafio" de 7 bytes, entao qualquer valor serve
    pro nosso teste).
    """
    payload = bytes([0x0b, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00]) + b"\x44\x03\xeb\x9b\x76\x01\x8a" + b"\x00"
    return encode_frame(payload)


def build_channel_list_frame(count: int = 5) -> bytes:
    """
    CLoginChannelListPacket (assinatura 02 06) -- formato real confirmado via decompilacao do Parse
    (FUN_0065083b): sig(2) + ??(6) + count:u16be @ offset 8 + ??(6) + array de count*u16be @ offset 16.
    O array parece ser so status/ID por mundo (2 bytes cada) -- os NOMES sao dados locais do client,
    nao vem pela rede (por isso nunca achamos os nomes nos pacotes capturados).
    """
    header = bytearray(16)
    header[0:2] = b"\x02\x06"
    struct.pack_into(">H", header, 8, count)
    entries = b"".join(struct.pack(">H", i) for i in range(count))
    payload = bytes(header) + entries
    return encode_frame(payload)


HOP2_HOST = "144.217.65.115"  # TESTE 22/08: IP real da maquina em vez de loopback -- hipotese de
                              # que o client ignora auto-connect automatico pra 127.0.0.1
HOP2_PORT = 20199


def build_channel_wait_frame() -> bytes:
    """
    CChannelWaitLoginPacket (assinatura 02 06) -- confirmado via captura real contra o servidor de
    producao (docs/../captures/real_success_full_flow_parsed.txt, pacote #20). Usando os bytes exatos
    capturados no caso de SUCESSO (offset 0x10 do payload = 01, nao 0x16 como na rejeicao).

    IMPORTANTE: bytes 22:28 do payload sao o ENDERECO DO HOP 2 (IP 4 bytes + porta u16
    little-endian, mesmo formato confirmado em channel_list_packet.py). Reescrevemos esses 6
    bytes pra apontar pro nosso channel_server.py local (HOP2_HOST:HOP2_PORT) em vez do servidor
    real de producao.

    ATUALIZADO 21/08 (captura nova, fluxo real completo com pktmon): os bytes 16:20 do payload NAO
    sao lixo/unknown -- sao `server_id` (u16 LE) + `channel_idx` (u16 LE) do canal PADRAO que o
    hop 2 vai representar (bate com o `channel_list_packet.py`: server_id=42="Azmara-Blackforest",
    channel_idx=2). A versao anterior desse frame (capturada ontem) tinha server_id=1/channel=99,
    que NAO bate com o servidor que a nossa lista mostra como nomeado (42) -- essa inconsistencia
    era um candidato a causa do crash; agora usamos valores consistentes (42/2) pra testar.
    """
    # TESTADO (22/08 tarde) e REVERTIDO: troquei pelos bytes reais exatos (server_id=28/channel=99)
    # -- resultado NOVO e importante: o client mostrou uma tela de "Escolhendo o mundo" DIFERENTE
    # (mapa-mundi com nomes reais, ex: "Bent-Gerot" = server_id 28), diferente da lista simples
    # que a gente via antes. Ou seja, o VALOR do server_id importa (nao e' so cosmetico) -- ele
    # decide qual UI o client mostra. Mas server_id=28 nao existe na nossa REAL_CHANNELS
    # (channel_server.py so tem o 42 configurado de verdade), e clicar nele crashou o jogo.
    # Revertido pra server_id=42/channel_idx=1 (mesma estrutura de 32 bytes da captura real, só
    # com os valores que sabemos que funcionam) pra nao perder o fluxo que ja funciona hoje.
    # Fica documentado como pista nova pro dia que formos atacar a tela dupla de verdade.
    # TESTADO e REVERTIDO (22/08 tarde): a captura real (pacote #20) mostra esse campo
    # ("channel_idx", offset 18) como 0x63=99, nao um canal concreto -- troquei pra 99 pra tentar
    # eliminar a tela dupla, mas isso fez o CLIENT CRASHAR ao clicar no servidor (provavelmente
    # trata 99 como indice de array e estoura limites, sem o contexto adicional que o servidor
    # real deve mandar em algum outro lugar que ainda nao replicamos). Revertido pra 1 (estado
    # conhecido que NAO crasha, so mostra a tela dupla).
    # TESTADO e REVERTIDO (22/08): tentei channel_idx=99 com o channel_server.py TAMBEM anunciando
    # uma entrada channel_idx=99 (pra testar se o crash era por causa do indice nao existir na
    # nossa lista) -- crashou EXATAMENTE IGUAL (mesma pilha de chamada, mesmo padrao no dump).
    # Descarta a hipotese "so precisa existir na lista". Revertido pra 1 (unico valor confirmado
    # seguro).
    # MAPA DE TESTES desse campo (offset 18), todos feitos em 22/08 com o resto igual:
    #   valor 1  -> UNICO que funciona (desenha 1 linha "Canal 1" -- a primeira tela fantasma)
    #   valor 0  -> CRASH ao clicar no servidor
    #   valor 99 -> CRASH ao clicar no servidor (mesma pilha, mesmo dump)
    # Como 0 tambem crasha, NAO e' uma "quantidade" (contagem 0 seria o caso mais seguro possivel).
    # O padrao (so o valor que existe de verdade na lista funciona, qualquer outro crasha) e' de um
    # find() cujo resultado e' usado sem checar "nao encontrado" -- por isso o iterator do std::map
    # estoura. Conclusao: nao existe valor aqui que faca a primeira tela sumir; o client sempre
    # desenha pelo menos uma linha. A tela e' eliminada por outro caminho (ver handle_client).
    payload = bytearray(bytes.fromhex(
        "02060000000300000000000100000000"
        "2a0001000000557795e17627"
        "00000000"
    ))
    import os
    # TESTE EMPIRICO 22/08: permite sobrescrever o server_id (offset 16, u16 LE) via env var,
    # pra ciclar 41/22/1 sem editar o arquivo -- so pra OBSERVAR o nome mostrado na tela
    # "Escolhendo o mundo", sem clicar (os servers que faltam nao estao implementados no
    # channel_server.py, clicar neles crasha).
    test_server_id = os.environ.get("ROSH_TEST_SERVER_ID")
    if test_server_id:
        struct.pack_into("<H", payload, 16, int(test_server_id))
    if os.environ.get("ROSH_REWRITE_HOP2") == "1":
        ip_bytes = bytes(int(x) for x in HOP2_HOST.split("."))
        struct.pack_into("4s", payload, 22, ip_bytes)
        struct.pack_into("<H", payload, 26, HOP2_PORT)
    return encode_frame(bytes(payload))


def build_middle_frame() -> bytes:
    """O 02 00 do fluxo de SUCESSO, com os bytes do oficial.

    ATE 30/08 ISTO ERA O FRAME DE RECUSA. Copiava-se a cabeca do
    `real_server_reject_01.log` -- 286 bytes, com os `ff ff ff ff` -- e mandava-se no caminho
    do sucesso. A cabeca da recusa tem UM BYTE A MENOS que a do sucesso, portanto tudo a
    partir do offset 12 ia deslocado:

        oficial  ... 00 00 00 01 00 2a 00 02   -> 1 mundo, servidor 42, canal 2
        nosso    ... 00 00 0a 00 01 00 00 ff   -> deslocado

    E DAI VINHAM AS CINCO LINHAS na tela "Escolhendo o mundo": o client procurava o numero de
    mundos no offset 13 e encontrava lixo. O jogador viu -- "quero remover esses lixos abaixo
    e deixar so a linha que o server chama".

    QUE O [13] E O NUMERO DE MUNDOS sabe-se por acidente: em 30/08 pôs-se 3 nesse byte do
    frame de RECUSA para tentar mudar a mensagem de erro, e o client desenhou a tela de
    escolha com tres mundos (ver CODIGO_RECUSA).

    O 02 00 de sucesso do oficial (290 bytes) e quase todo zeros:

        [0..17]    02 00 | seq | 00*7 | 01 | 00 | 2a 00 | 02 00
        [277..285] "15550140" -- um identificador de sessao, que NAO se copia: e da sessao
                   deles. Se um dia o client precisar de algo ali, aparece.
    """
    payload = bytearray(290)
    payload[0:18] = bytes([
        0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00,
        0x01,        # [13] numero de mundos
        0x00,
        0x2a, 0x00,  # [15:17] servidor 42, o unico que o channel_server tem a serio
        0x02,        # [17] canal
    ])
    return encode_frame(bytes(payload))


SEGUNDOS_ESPERA_RECUSA = 3.0
"""Quanto se espera pela resposta do client depois de mandar a recusa, antes de fechar.

Na captura do oficial o client respondeu 41 ms depois. Tres segundos e folga larga --
o que nao pode e fechar no mesmo instante, que era o que se fazia."""

CAMINHO_RECUSA_02_00 = r"D:\RoshEmulator\captures\recusa_02_00.bin"
CAMINHO_RECUSA_02_06 = r"D:\RoshEmulator\captures\recusa_02_06.bin"


CODIGO_RECUSA = 0x0a
"""O byte [13] do 02 00 da recusa. DEIXAR EM 0x0a, que e o que se capturou do oficial.

NAO E UM CODIGO DE MOTIVO -- foi isso que se pensou e esta errado. E o NUMERO DE MUNDOS da
lista de servidores. Pôs-se 3 aqui para testar e o client deu o login por BOM: desenhou a
tela de escolha com tres mundos, e uma senha errada passou a entrar. Dois minutos de brecha
por um palpite num campo que faz parte da resposta de SUCESSO.

A MENSAGEM NAO SE ESCOLHE DAQUI. O client tem uma tabela de motivos (FUN_00644745 do
AMo.exe) que traduz um numero numa mensagem -- o 3 e "Nome de usuario ou senha invalidos" e
o 661 e "A sua autorizacao expirou". Mas o 661 NAO VEM NOS BYTES QUE MANDAMOS: procurou-se
nos dois ficheiros da recusa, em BE e LE, e nao esta. E no binario inteiro o 0x295 nao
aparece uma unica vez como constante. O client inventa-o quando conclui que o login nao
completou.

O que se corrigiu, e era o defeito a serio, foi a recusa DESLIGAR EM SILENCIO: fechava-se o
socket no instante a seguir ao sendall e o client nao chegava a desenhar o popup. Ver
`recusar`.

FICA ASSIM DE PROPOSITO (30/08). O jogador decidiu passar o login para TOKEN mais a frente,
e ai o erro de credenciais deixa de ser do client e passa a ser do launcher. Mudar a string
47779 do string.lod para "senha invalida" arrumava a redaccao hoje e ficava errada depois.
"""


def build_recusa_frames() -> bytes:
    """Resposta completa de login RECUSADO, com os bytes EXATOS do servidor oficial.

    Capturado em captures/real_server_reject_01.log, onde o client mostrou o popup
    "A sua autorizacao expirou" e a conexao foi encerrada pelo servidor. Sao TRES frames:

        0b 00  handshake            15 bytes
        02 00  resposta de login   286 bytes   -- [13] = 0x0a (recusado; 0x01 = aceito)
        02 06                       32 bytes

    ERRO DE 23/08: eu mandava so os dois primeiros, e com o corpo do 02 00 zerado. O client
    nao mostrava mensagem -- ele FECHAVA. Faltava o 02 06, e o corpo zerado tambem nao era o
    que ele viu no servidor real. Agora vao os bytes originais dos tres, sem invencao.

    O corpo do 02 00 real contem vazamento de memoria do servidor oficial (caminhos de boost).
    E lixo, mas e o lixo que o client sabe ignorar -- por isso reproduzimos verbatim.
    """
    partes = [build_handshake_frame()]
    for caminho in (CAMINHO_RECUSA_02_00, CAMINHO_RECUSA_02_06):
        try:
            with open(caminho, "rb") as f:
                corpo = bytearray(f.read())
        except OSError as e:
            print(f"[!] nao consegui ler {caminho}: {e}")
            continue
        if caminho == CAMINHO_RECUSA_02_00 and len(corpo) > 13:
            corpo[13] = CODIGO_RECUSA & 0xFF
        partes.append(encode_frame(bytes(corpo)))
    return b"".join(partes)


def build_experimental_login_response(new_token: str = "teststoken") -> bytes:
    """
    Melhor palpite atual pro formato de resposta (NAO confirmado empiricamente ainda).

    Cabecalho: assinatura 02 00 + zeros ate os offsets de porta.
    offsets 0x8, 0xa, 0xc, 0xe, 0x10 (ntohs no client) = portas de sub-servidor.
    offset 0x12 (ntohl) = valor numerico (uso desconhecido, testando com 0).
    Bloco de strings a partir de 0x115: reaproveitamos o slot 2 (indice 1, "PW" no request)
    pro novo token de sessao, ja que e o que o client copia pra DAT_008dc9b8 quando
    porta(offset 0xc) != 10.
    """
    header = bytearray(STRINGS_OFFSET)
    header[0:2] = SIGNATURE
    struct.pack_into(">H", header, 0x8, 10001)   # porta "principal"/world, chute
    struct.pack_into(">H", header, 0xA, 0)
    struct.pack_into(">H", header, 0xC, 10)      # ==10 -> NAO troca token, mantem sessao atual (novo teste)
    struct.pack_into(">H", header, 0xE, 0)
    struct.pack_into(">H", header, 0x10, 0)
    struct.pack_into(">I", header, 0x12, 0)

    strings = [
        "",
        new_token,
        "",
        "",
        "",
    ]
    body = bytes(header) + b"\x00".join(s.encode("latin-1") for s in strings) + b"\x00"
    return encode_frame(body)


REAL_SERVER_IDS_HOP1 = [42, 41, 28, 22, 1]
REAL_CHANNELS_HOP1 = {
    42: ("127.0.0.1", [20199]),
    41: ("127.0.0.1", [20199, 20199]),
    28: ("127.0.0.1", [20199, 20199, 20199]),
    22: ("127.0.0.1", [20199, 20199, 20199]),
    1: ("127.0.0.1", [20199, 20199, 20199, 20199, 20199, 20199]),
}


def build_channel_list_hop1_frame() -> bytes:
    """
    TESTE (pedido do usuario, 22/08): mandar a lista de canais REAL (mesmo formato/dado que o
    channel_server.py manda no hop 2) JUNTO com o chanwait, pelo HOP 1 -- hipotese: se o client ja
    tiver esse dado na mao antes de desenhar a tela de canal, talvez pule o placeholder
    "Canal X/Baixo" e va direto pra lista completa.
    """
    channels = []
    for sid in REAL_SERVER_IDS_HOP1:
        ip, ports = REAL_CHANNELS_HOP1[sid]
        for idx, port in enumerate(ports, start=1):
            channels.append(ChannelEntry(server_id=sid, channel_idx=idx, status_flag=1, ip=ip, port=port))
    pkt = ChannelListPacket(server_ids=REAL_SERVER_IDS_HOP1, channels=channels)
    return encode_frame(pkt.encode_payload())


def build_channel_change_response(origem: str | None = None) -> bytes:
    """
    Resposta ao pacote NOVO descoberto em 21/08 (assinatura 02 07, "pedido de troca de canal") --
    o client manda esse pedido de volta na MESMA conexao onde recebeu o chanwait (confirmamos que
    no fluxo real isso vai pro hop 2 ja conectado; no nosso teste, como o hop 2 nunca foi
    contactado, o client manda de volta pro hop 1). Usamos como base os bytes EXATOS da resposta
    real capturada (pacote #70929, 24 bytes de payload, hop 2 -> client) e so reescrevemos os 6
    bytes de endereco (offset 14:20, IP+porta, mesmo formato ja confirmado em outros pacotes) pro
    nosso channel_server.py -- os demais bytes (inclusive os 4 finais que ainda nao entendemos
    completamente) ficam EXATAMENTE como na captura real, por seguranca.
    """
    payload = bytearray(bytes.fromhex(
        "020700000005000100010000000" "0e0957755277500002a76"
    ))
    # TESTE: bytes reais (e0 95 77 55 27 75) parecem ser IP com OCTETOS INVERTIDOS (reverse(e0
    # 95 77 55) = 55 77 95 e0 = 85.119.149.224) + porta BIG-ENDIAN (27 75 = 0x2775 = 10101) --
    # ordem DIFERENTE do resto do protocolo (que usa IP normal + porta LE). Testando essa hipotese.
    # O ENDERECO QUE MANDAMOS TEM QUE SER O MESMO POR ONDE O CLIENT CHEGOU.
    #
    # A conta e identificada no mundo pelo IP de origem, comparado com a sessao que o login
    # gravou. Mandando um endereco fixo, os dois divergiam quando o jogo rodava na propria
    # maquina do servidor:
    #
    #     login  chegava de 127.0.0.1        -> sessao gravada como 127.0.0.1
    #     nos respondiamos "o mundo e 144.217.65.115"
    #     mundo  chegava de 144.217.65.115   -> nao batia, conta nao identificada
    #
    # O jogador via isso como "entrei e diz que nao tenho personagem". Espelhando o caminho
    # de chegada, quem entra por loopback continua no loopback e quem entra de fora continua
    # de fora, e o IP bate nos dois casos.
    host = HOP2_HOST
    if origem and origem.startswith("127."):
        host = origem
    ip_bytes_reversed = bytes(reversed([int(x) for x in host.split(".")]))
    struct.pack_into("4s", payload, 14, ip_bytes_reversed)
    struct.pack_into(">H", payload, 18, HOP2_PORT)
    return encode_frame(bytes(payload))


def recusar(conn: socket.socket, addr, motivo: str, quem: str = "") -> None:
    """Manda a recusa e DA TEMPO AO CLIENT de a desenhar antes de fechar.

    O `return` seco fechava o socket no instante a seguir ao sendall, e o jogador via a
    ligacao cair sem mensagem nenhuma -- "quando coloca senha errada da dando disconect, nao
    aparece nada de senha errada".

    Na captura de rejeicao do oficial (captures/real_server_reject_01.log) o servidor manda
    os 345 bytes, ESPERA a resposta de 19 bytes do client (o eco do 0b 00) e so entao e que a
    ligacao cai. Aqui faz-se o mesmo: le-se ate o client responder ou ate esgotar o prazo.
    """
    print(f"[!] LOGIN RECUSADO para '{quem}' de {addr}: {motivo}")
    try:
        conn.sendall(build_recusa_frames())
    except OSError:
        return
    try:
        conn.settimeout(SEGUNDOS_ESPERA_RECUSA)
        while True:
            if not conn.recv(4096):
                break
    except OSError:
        pass


def handle_client(conn: socket.socket, addr):
    print(f"[+] conexao de {addr}")
    try:
        body = read_frame(conn)
        print(f"[+] frame recebido de {addr}: {len(body)} bytes")
        try:
            req = parse_login_request(body)
            print(f"[+] LOGIN REQUEST de {addr}: {req}")
            # Registra a conta no banco. Nao valida senha ainda -- qualquer credencial entra,
            # como antes. So passamos a saber QUEM entrou e quando.
            resultado, conta_id = db.autenticar(req.id, req.pw, addr[0] if addr else None)
            if resultado != "ok":
                motivo = {
                    "senha_errada": "senha incorreta",
                    "bloqueada": "conta bloqueada",
                    "desconhecida": "conta inexistente",
                    "sem_banco": "banco indisponivel (recusa por seguranca)",
                }.get(resultado, resultado)
                recusar(conn, addr, motivo, req.id)
                return
            if not db.registrar_dispositivo(req.id, req.hash1, req.hash2):
                recusar(conn, addr,
                        f"limite de {db.MAX_DISPOSITIVOS} maquinas atingido "
                        f"-- precisa de liberacao", req.id)
                return
            # O MUNDO TEM LOTACAO. Recusa-se AQUI e nao a entrada no mundo, porque aqui
            # ha caminho para o jogador ver um aviso -- ver `recusar`. La ele so veria a
            # ligacao cair sem explicacao.
            #
            # FALHA PARA O LADO SEGURO: se a contagem faltar ou estiver velha, `ha_lugar`
            # devolve True e deixa entrar. O contrario trancava toda a gente fora sempre que
            # o world_server tropecasse -- incluindo quem fosse ver o que se passava.
            #
            # HA UMA JANELA, e fica dita: a contagem so mexe quando alguem ENTRA no mundo,
            # portanto varios a autenticar-se ao mesmo tempo passam todos. Com lotacao de 10
            # isso e um ou dois a mais no pior caso, e nao vale um travao entre processos.
            _dentro = lotacao.quantos_online()
            if not lotacao.ha_lugar(_dentro):
                recusar(conn, addr,
                        f"mundo cheio ({_dentro}/{lotacao.CAPACIDADE})", req.id)
                return
            print(f"[+] login de '{req.id}' autenticado (conta_id={conta_id}) "
                  f"-- {_dentro}/{lotacao.CAPACIDADE} no mundo, "
                  f"estado {lotacao.NOMES[lotacao.estado(_dentro)]}")
            # Guarda a identidade pro hop3: la o client manda id='' e um token, entao esta e
            # a unica chance de saber quem entrou.
            db.registrar_sessao(addr[0] if addr else "", req.id, req.hash1, req.hash2)
        except FrameError as e:
            print(f"[!] nao consegui parsear como login request: {e}")
            return

        # ORDEM RE-CORRIGIDA (22/08 noite): a ordem anterior (handshake -> middle -> chanwait)
        # estava ERRADA. Ela veio de uma leitura do `real_server_reject_01.log` (fluxo de
        # REJEICAO), nao do fluxo de sucesso. Conferindo pacote a pacote em
        # captures/real_success_full_flow_parsed.txt, o servidor real de producao manda:
        #     #19 handshake (19 bytes)
        #     #20 chanwait 02 06 (36 bytes)     <-- ANTES do middle!
        #     #21 middle 02 00 (290 bytes)
        # e logo em seguida (#25/#26, ~150ms depois) o client fecha o hop1 SOZINHO e abre o hop2,
        # SEM nunca mandar um "02 07". Ou seja: no fluxo real NAO existe a primeira tela de
        # "Selecao de canal" -- o client vai direto pro hop2. A hipotese da sessao anterior ("o
        # client so auto-conecta quando recebe o chanwait na posicao certa") estava certa, so que
        # a ordem foi aplicada invertida. Aqui mandamos na ordem real: handshake, chanwait, middle.
        handshake = build_handshake_frame()
        chanwait = build_channel_wait_frame()
        middle = build_middle_frame()
        conn.sendall(handshake)
        conn.sendall(chanwait)
        conn.sendall(middle)
        print(f"[+] handshake ({len(handshake)}) + CChannelWaitLoginPacket 02 06 ({len(chanwait)}) "
              f"+ frame do meio ({len(middle)}) enviados pra {addr} (ORDEM REAL)")

        # PULAR A PRIMEIRA TELA DE CANAL (22/08 noite):
        # No fluxo real o client fecha o hop1 sozinho ~150ms depois do chanwait e vai direto pro
        # hop2, SEM nunca mandar um "02 07" -- ou seja, a primeira tela de "Selecao de canal" nao
        # existe. No nosso caso o client desenha essa tela e fica esperando o clique do usuario,
        # que so entao gera o "02 07". Ja provamos que nenhum valor do chanwait evita isso
        # (ver build_channel_wait_frame: 1 funciona, 0 e 99 crasham).
        # Entao aqui a gente EMPURRA a resposta de troca de canal (o pacote com o endereco do
        # hop2) sem esperar o pedido -- e' exatamente a mesma resposta que mandariamos se o
        # usuario tivesse clicado, so que imediatamente. Se o client processar, ele pula pro hop2
        # sem precisar do clique, e a primeira tela nunca chega a ser usada.
        _origem = addr[0] if addr else None
        push = build_channel_change_response(_origem)
        conn.sendall(push)
        _host = _origem if (_origem or "").startswith("127.") else HOP2_HOST
        print(f"[+] 02 07 EMPURRADO sem esperar clique ({len(push)} bytes) -- endereco do hop2 "
              f"{_host}:{HOP2_PORT} pra {addr}")

        # TESTAMOS fechar ativamente logo depois do eco do handshake (hipotese de que o client
        # esperava isso) -- PIOROU (deu erro 10038 imediatamente no login, antes nem de ver a
        # lista, em vez de so ao clicar). Revertido: fechar ativo nao e' o que falta. Voltamos a
        # esperar passivamente (o client controla quando fecha, como nas capturas reais).
        conn.settimeout(30)
        while True:
            try:
                body = read_frame(conn)
                print(f"[+] frame adicional de {addr}: {len(body)} bytes: {body[:64]!r}...")
                if len(body) >= 2 and body[0:2] == b"\x02\x07":
                    resp = build_channel_change_response()
                    conn.sendall(resp)
                    print(f"[+] respondido pedido de troca de canal (02 07) com endereco de "
                          f"{HOP2_HOST}:{HOP2_PORT} ({len(resp)} bytes) pra {addr}")
            except FrameError as e:
                print(f"[+] conexao fechada pelo client (ou erro de framing): {e}")
                break
            except socket.timeout:
                print(f"[+] timeout de 30s esperando o client -- fechando por aqui")
                break
    except FrameError as e:
        print(f"[!] erro de framing com {addr}: {e}")
    except Exception as e:
        print(f"[!] erro com {addr}: {e}")
    finally:
        conn.close()
        print(f"[-] conexao {addr} fechada")


def main():
    port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, port))
    srv.listen(5)
    print(f"login_server escutando em {HOST}:{port}")
    while True:
        conn, addr = srv.accept()
        t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
        t.start()


if __name__ == "__main__":
    main()
