# -*- coding: utf-8 -*-
r"""Junta num ZIP tudo o que se perde se a VPS morrer, e copia-o para o Google Drive.

    python tools/backup.py                     o do costume
    python tools/backup.py --sem-capturas      nao mexe no ZIP das gravacoes
    python tools/backup.py --forcar-capturas   refaz o das gravacoes mesmo sem mudancas
    python tools/backup.py --com-transcricoes  junta o historico das conversas ao diario
    python tools/backup.py --sem-banco         salta o dump do SQL Server

SAO DOIS ZIPS, E O PORQUE IMPORTA. O diario tem 6 MB e muda todos os dias; as gravacoes do
oficial tem 300 MB e mudam raramente. Metidas no mesmo ficheiro subiam 300 MB por dia para
a nuvem sempre iguais -- 1,6 GB por mes de repeticao, e os 15 GB gratis do Drive cheios em
nove meses. Separadas, o das gravacoes so e refeito quando alguma delas muda.

    rosh_AAAAMMDD_HHMM.zip            diario  -- codigo, docs, banco, memoria
    rosh_capturas_AAAAMMDD_HHMM.zip   so quando as gravacoes mudam

O QUE ENTRA NO DIARIO:

    server/  tools/  docs/     o trabalho todo -- codigo, ferramentas e os achados
    admin/  schema/            o painel web e os .sql que criam o banco
    patch_client/  dist/       o que se distribui aos jogadores
    server/Data/               as tabelas extraidas do client
    banco Rosh (.bak)          contas, personagens, itens, skills, buffs
    memoria do Claude          .claude/projects/D--Cabal/memory -- 124 KB que so existem
                               NESTE disco e que nao se reconstroem de lado nenhum

O QUE ENTRA NO DAS GRAVACOES: `C:\RoshHook` (onde o hook escreve) e
`Desktop\CapturaKaros` (para onde o jogador as arruma). Sao a unica coisa deste backup que
nao se refaz -- o codigo e o banco reconstroem-se, estas medicoes so se recuperam voltando
a jogar no oficial e a gravar tudo de novo.

O QUE NUNCA ENTRA: a pasta `captures/` tem 22 GB e 17 deles sao um log NOSSO
(`espiao_movimento.log`), que se refaz ligando o espiao outra vez. Os .etl e .pcapng de
Agosto sao gravacoes de rede em bruto de que ja se extraiu o que interessava.

CUIDADO ANTES DE PÔR ISTO NUMA NUVEM: o ZIP diario leva a cadeia de ligacao ao banco
(dentro do db.py) e a tabela `conta`, com os hashes das senhas dos jogadores. Sao hashes
PBKDF2 com salt, nao senhas em claro, mas continua a ser material que so deve ir para uma
conta tua e privada.
"""
import argparse
import hashlib
import io
import os
import subprocess
import zipfile

RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DESTINO = os.path.join(RAIZ, "backups")
PASTA_CLAUDE = os.path.join(os.path.expanduser("~"), ".claude", "projects", "D--Cabal")
MEMORIA = os.path.join(PASTA_CLAUDE, "memory")
BANCO = "Rosh"

# AS GRAVACOES DO SERVIDOR OFICIAL, que vivem em dois sitios: a pasta onde o hook escreve e
# a pasta para onde o jogador as arruma a mao.
CAPTURAS_OFICIAL = [
    r"C:\RoshHook",
    r"C:\Users\Administrator\Desktop\CapturaKaros",
]
MARCA_CAPTURAS = os.path.join(DESTINO, "capturas.marca")
"""Impressao digital das gravacoes na ultima vez que se fez o ZIP delas."""

# As quatro ultimas faltavam ate 03/09/2026, e a que doia era `schema`: sem ela, um .bak
# que nao restaurasse deixava o banco irrecuperavel, porque sao esses scripts que o criam
# do zero. Somadas dao 400 KB -- nao havia razao para ficarem de fora.
#
#   admin          o painel web (contas, dispositivos, personagens)
#   schema         os .sql que criam e migram o banco
#   patch_client   o ZIP e o ServerList.ten que se dao aos jogadores
#   dist           CapturaKaros e GravadorAxion
PASTAS = ("server", "tools", "docs", "admin", "schema", "patch_client", "dist")
SALTAR_PASTA = ("__pycache__", ".git", "node_modules")
SALTAR_EXT = (".pyc", ".pyo")
MAX_FICHEIRO = 200 * 1024 * 1024

# ONDE O GOOGLE DRIVE APARECE, quando estiver instalado e com sessao iniciada.
#
# O Drive para computador monta uma unidade (por omissao G:) e a pasta muda de nome com o
# idioma. Procuram-se todas em vez de se cravar uma, para o backup comecar a sair da VPS no
# instante em que o programa for instalado, sem ninguem ter de vir aqui mexer.
#
# NAO E ESTE O BACKUP: o ZIP fica sempre em backups/ mesmo que nada disto exista.
PASTAS_NUVEM = [
    r"G:\Meu Drive", r"G:\O meu disco", r"G:\My Drive",
    r"H:\Meu Drive", r"H:\O meu disco", r"H:\My Drive",
    os.path.join(os.path.expanduser("~"), "Google Drive"),
    os.path.join(os.path.expanduser("~"), "My Drive"),
]
SUBPASTA_NUVEM = "RoshBackup"


def carimbo():
    """AAAAMMDD_HHMM da hora local."""
    import datetime
    return datetime.datetime.now().strftime("%Y%m%d_%H%M")


def copiar_para_nuvem(zipe):
    """Copia o ZIP para a pasta do Drive, se ela existir. Devolve o destino ou None."""
    import shutil
    for base in PASTAS_NUVEM:
        if not os.path.isdir(base):
            continue
        alvo = os.path.join(base, SUBPASTA_NUVEM)
        try:
            os.makedirs(alvo, exist_ok=True)
            destino = os.path.join(alvo, os.path.basename(zipe))
            shutil.copy2(zipe, destino)
            return destino
        except OSError as e:
            print("  [nuvem] %s: %s" % (base, str(e)[:100]))
    return None


def impressao(bases):
    """Impressao digital do conteudo das pastas: caminho, tamanho e data de cada ficheiro.

    NAO se le o conteudo. Ler 300 MB a cada corrida so para decidir se mudou seria pior do
    que o problema que isto resolve. Caminho + tamanho + mtime chega: uma gravacao nova traz
    nome novo, e uma que cresça traz tamanho novo.
    """
    h = hashlib.sha256()
    for base in sorted(bases):
        if not os.path.isdir(base):
            continue
        for pasta, dirs, ficheiros in os.walk(base):
            dirs.sort()
            for f in sorted(ficheiros):
                cam = os.path.join(pasta, f)
                try:
                    st = os.stat(cam)
                except OSError:
                    continue
                h.update(("%s|%d|%d;" % (os.path.relpath(cam, base), st.st_size,
                                         int(st.st_mtime))).encode("utf-8", "replace"))
    return h.hexdigest()


def dump_do_banco(pasta):
    """BACKUP DATABASE nativo, comprimido. Devolve o caminho ou None.

    Corre pelo sqlcmd e nao pelo pyodbc de proposito: o BACKUP e do SERVICO do SQL Server e
    o ficheiro sai com o dono dele, portanto a pasta tem de ser gravavel por esse servico.
    """
    alvo = os.path.join(pasta, "%s_%s.bak" % (BANCO, carimbo()))
    sql = ("BACKUP DATABASE [%s] TO DISK = N'%s' WITH INIT, COMPRESSION, "
           "NAME = N'backup automatico'" % (BANCO, alvo))
    try:
        r = subprocess.run(["sqlcmd", "-S", ".", "-E", "-b", "-Q", sql],
                           capture_output=True, text=True, timeout=900)
    except FileNotFoundError:
        print("  [banco] sqlcmd nao encontrado -- o ZIP vai sem o dump")
        return None
    except subprocess.TimeoutExpired:
        print("  [banco] o BACKUP passou dos 15 minutos -- abandonado")
        return None
    if r.returncode != 0 or not os.path.exists(alvo):
        print("  [banco] BACKUP falhou: %s" % (r.stderr or r.stdout or "").strip()[:300])
        return None
    print("  [banco] %s (%.1f MB)" % (os.path.basename(alvo),
                                      os.path.getsize(alvo) / 1048576.0))
    return alvo


def juntar(z, base, prefixo):
    n = bytes_ = 0
    for pasta, dirs, ficheiros in os.walk(base):
        dirs[:] = [d for d in dirs if d not in SALTAR_PASTA]
        for f in ficheiros:
            if os.path.splitext(f)[1].lower() in SALTAR_EXT:
                continue
            cam = os.path.join(pasta, f)
            try:
                tam = os.path.getsize(cam)
            except OSError:
                continue
            if tam > MAX_FICHEIRO:
                print("  [salta] %s (%.0f MB, acima do limite)"
                      % (os.path.relpath(cam, base), tam / 1048576.0))
                continue
            try:
                z.write(cam, os.path.join(prefixo, os.path.relpath(cam, base)))
            except OSError as e:
                print("  [erro] %s: %s" % (cam, str(e)[:80]))
                continue
            n += 1
            bytes_ += tam
    return n, bytes_


def rodar(prefixo, manter):
    """Apaga os mais antigos DAQUI. A nuvem nao e tocada -- la acumulam de proposito."""
    if manter <= 0:
        return
    zips = sorted(f for f in os.listdir(DESTINO)
                  if f.startswith(prefixo) and f.endswith(".zip"))
    for velho in zips[:-manter]:
        try:
            os.remove(os.path.join(DESTINO, velho))
            print("  [rodou] apagado o antigo %s" % velho)
        except OSError:
            pass


def backup_das_capturas(forcar=False):
    """Refaz o ZIP das gravacoes do oficial SO se alguma mudou. Devolve o caminho ou None."""
    agora = impressao(CAPTURAS_OFICIAL)
    antes = ""
    try:
        antes = io.open(MARCA_CAPTURAS, encoding="utf-8").read().strip()
    except OSError:
        pass
    if agora == antes and not forcar:
        print("[capturas] nada mudou desde a ultima vez -- ZIP nao refeito")
        return None

    nome = os.path.join(DESTINO, "rosh_capturas_%s.zip" % carimbo())
    total_n = total_b = 0
    with zipfile.ZipFile(nome, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
        for base in CAPTURAS_OFICIAL:
            if not os.path.isdir(base):
                print("[capturas] %s nao existe -- saltada" % base)
                continue
            rot = os.path.basename(base.rstrip(chr(92)))
            n, b = juntar(z, base, rot)
            print("  [%s] %d ficheiros, %.1f MB" % (rot, n, b / 1048576.0))
            total_n += n
            total_b += b
    if not total_n:
        os.remove(nome)
        print("[capturas] nenhuma gravacao encontrada -- ZIP descartado")
        return None
    print("%s" % nome)
    print("  %d ficheiros, %.1f MB em bruto -> %.1f MB comprimido"
          % (total_n, total_b / 1048576.0, os.path.getsize(nome) / 1048576.0))
    fora = copiar_para_nuvem(nome)
    print(("  copiado para a nuvem: %s" % fora) if fora
          else "  (Google Drive nao encontrado -- ficou so nesta maquina)")
    # A MARCA SO SE ESCREVE NO FIM. Se algo rebentar a meio, a proxima corrida tenta outra
    # vez em vez de dar por feito um ZIP que nao chegou a existir.
    try:
        io.open(MARCA_CAPTURAS, "w", encoding="utf-8", newline="").write(agora)
    except OSError as e:
        print("  [capturas] nao consegui gravar a marca: %s" % str(e)[:90])
    rodar("rosh_capturas_", 3)
    return nome


def main():
    ap = argparse.ArgumentParser(description="Backup do servidor, do banco e da memoria.")
    ap.add_argument("--sem-banco", action="store_true",
                    help="nao correr o BACKUP DATABASE")
    ap.add_argument("--sem-capturas", action="store_true",
                    help="nao mexer no ZIP das gravacoes do oficial")
    ap.add_argument("--forcar-capturas", action="store_true",
                    help="refazer o ZIP das gravacoes mesmo que nada tenha mudado")
    ap.add_argument("--com-transcricoes", action="store_true",
                    help="incluir as transcricoes das sessoes (283 MB) no diario")
    ap.add_argument("--manter", type=int, default=7,
                    help="quantos backups diarios guardar aqui (0 = todos)")
    args = ap.parse_args()

    os.makedirs(DESTINO, exist_ok=True)
    nome = os.path.join(DESTINO, "rosh_%s.zip" % carimbo())
    bak = None if args.sem_banco else dump_do_banco(DESTINO)

    total_n = total_b = 0
    with zipfile.ZipFile(nome, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
        for p in PASTAS:
            base = os.path.join(RAIZ, p)
            if not os.path.isdir(base):
                continue
            n, b = juntar(z, base, p)
            print("  [%s] %d ficheiros, %.1f MB" % (p, n, b / 1048576.0))
            total_n += n
            total_b += b

        # A MEMORIA VAI SEMPRE. Sao 124 KB e e a UNICA copia que existe: o jogador
        # perguntou "as memoria sua fica salvo local aqui?" e fica -- neste disco e mais
        # lado nenhum. Custa nada e e do mais caro de reconstruir que aqui ha.
        if os.path.isdir(MEMORIA):
            n, b = juntar(z, MEMORIA, "memoria_claude")
            print("  [memoria_claude] %d ficheiros, %.0f KB" % (n, b / 1024.0))
            total_n += n
            total_b += b

        if args.com_transcricoes and os.path.isdir(PASTA_CLAUDE):
            n = 0
            for f in sorted(os.listdir(PASTA_CLAUDE)):
                if not f.endswith(".jsonl"):
                    continue
                cam = os.path.join(PASTA_CLAUDE, f)
                z.write(cam, os.path.join("transcricoes", f))
                n += 1
                total_n += 1
                total_b += os.path.getsize(cam)
            print("  [transcricoes] %d ficheiros" % n)

        if bak:
            z.write(bak, os.path.join("banco", os.path.basename(bak)))
            total_n += 1
            total_b += os.path.getsize(bak)
    if bak:
        try:
            os.remove(bak)                       # ja esta dentro do ZIP
        except OSError:
            pass

    print()
    print("%s" % nome)
    print("  %d ficheiros, %.1f MB em bruto -> %.1f MB comprimido"
          % (total_n, total_b / 1048576.0, os.path.getsize(nome) / 1048576.0))
    fora = copiar_para_nuvem(nome)
    print(("  copiado para a nuvem: %s" % fora) if fora
          else "  (Google Drive nao encontrado -- ficou so nesta maquina)")

    # ROTACAO. Sem isto a pasta cresce sem fim, e o disco que ela enche e o mesmo que se
    # esta a tentar proteger.
    rodar("rosh_2", args.manter)

    if not args.sem_capturas:
        print()
        backup_das_capturas(forcar=args.forcar_capturas)
    return 0


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