#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DFCode Suporte Remoto - CENTRAL
Roda na VPS. Os clientes (DFCodeSuporte.exe) ficam conectados aqui com seu ID,
e os tecnicos (DFCodeTecnico.exe) pedem acesso por ID. A central faz a ponte.

Uso:
  python3 central.py servir                    inicia a central (o systemd usa este)
  python3 central.py adicionar-tecnico NOME    cria/troca a senha de um tecnico
  python3 central.py remover-tecnico NOME      remove um tecnico
  python3 central.py tecnicos                  lista os tecnicos
  python3 central.py clientes                  lista os IDs cadastrados
Compativel com Python 3.6 ou mais novo, sem bibliotecas extras.
"""
import asyncio
import getpass
import hashlib
import hmac
import json
import os
import secrets
import ssl
import sys
import time

PASTA = os.path.dirname(os.path.abspath(__file__))
ARQ_CONF = os.path.join(PASTA, "central.conf")
PASTA_DADOS = os.path.join(PASTA, "dados")
ARQ_IDS = os.path.join(PASTA_DADOS, "clientes.json")
ARQ_TECNICOS = os.path.join(PASTA_DADOS, "tecnicos.json")
ARQ_LOG = os.path.join(PASTA_DADOS, "acessos.log")
ARQ_HIST = os.path.join(PASTA_DADOS, "historico.jsonl")

TEMPO_LINHA = 20          # segundos para receber a primeira mensagem
TEMPO_PERMISSAO = 60      # segundos esperando o cliente aceitar
TEMPO_SEM_PING = 100      # cliente sem dar sinal e considerado offline
MAX_LINHA = 8192


# ----------------------------------------------------------------------
#  Configuracao e arquivos de dados
# ----------------------------------------------------------------------
def ler_conf():
    conf = {"porta": "5960", "certificado": "certificado.pem", "chave": "chave.pem"}
    if os.path.exists(ARQ_CONF):
        with open(ARQ_CONF, encoding="utf-8") as f:
            for linha in f:
                linha = linha.strip()
                if not linha or linha.startswith("#") or "=" not in linha:
                    continue
                k, v = linha.split("=", 1)
                conf[k.strip().lower()] = v.strip()
    return conf


def ler_json(arq, padrao):
    try:
        with open(arq, encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return padrao


def gravar_json(arq, dados):
    os.makedirs(os.path.dirname(arq), exist_ok=True)
    tmp = arq + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(dados, f, ensure_ascii=False, indent=1)
    os.replace(tmp, arq)


def hash_senha(senha, sal=None):
    if sal is None:
        sal = secrets.token_hex(16)
    h = hashlib.pbkdf2_hmac("sha256", senha.encode("utf-8"), sal.encode("ascii"), 120000).hex()
    return sal + "$" + h


def confere_senha(senha, guardado):
    try:
        sal, _ = guardado.split("$", 1)
    except ValueError:
        return False
    return hmac.compare_digest(hash_senha(senha, sal), guardado)


def sha(texto):
    return hashlib.sha256(texto.encode("utf-8")).hexdigest()


def log(msg):
    linha = time.strftime("%d/%m/%Y %H:%M:%S") + "  " + msg
    print(linha, flush=True)
    try:
        os.makedirs(PASTA_DADOS, exist_ok=True)
        with open(ARQ_LOG, "a", encoding="utf-8") as f:
            f.write(linha + "\n")
    except Exception:
        pass


def historico(evento, tecnico, cid, cliente, duracao=0, mb=0.0, detalhe=""):
    """Grava uma linha no historico (lido pelo programa do tecnico)."""
    reg = {"data": time.strftime("%d/%m/%Y %H:%M:%S"), "evento": evento, "tecnico": tecnico,
           "id": cid, "cliente": cliente, "duracao": int(duracao), "mb": round(mb, 2),
           "detalhe": detalhe[:300]}
    try:
        os.makedirs(PASTA_DADOS, exist_ok=True)
        if os.path.exists(ARQ_HIST) and os.path.getsize(ARQ_HIST) > 30 * 1048576:
            os.replace(ARQ_HIST, ARQ_HIST + ".antigo")
        with open(ARQ_HIST, "a", encoding="utf-8") as f:
            f.write(json.dumps(reg, ensure_ascii=False) + "\n")
    except Exception as e:
        log("Erro gravando historico: %r" % e)


def ler_historico(cid, limite):
    itens = []
    try:
        with open(ARQ_HIST, encoding="utf-8") as f:
            for linha in f:
                try:
                    r = json.loads(linha)
                except ValueError:
                    continue
                if cid and r.get("id") != cid:
                    continue
                itens.append(r)
                if len(itens) > limite * 2:
                    itens = itens[-limite:]
    except FileNotFoundError:
        pass
    itens = itens[-limite:]
    itens.reverse()   # mais recentes primeiro
    return itens


# ----------------------------------------------------------------------
#  Estado da central
# ----------------------------------------------------------------------
class Cliente:
    def __init__(self, cid, nome, modo, writer, ip):
        self.id = cid
        self.nome = nome
        self.modo = modo
        self.writer = writer
        self.ip = ip
        self.ultimo_sinal = time.time()
        self.trava = asyncio.Lock()

    async def enviar(self, obj):
        async with self.trava:
            self.writer.write((json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8"))
            await self.writer.drain()


class Central:
    def __init__(self):
        self.clientes_db = ler_json(ARQ_IDS, {})       # id -> {token, nome, criado, visto}
        self.online = {}                                # id -> Cliente
        self.pendentes = {}                             # sessao -> Future
        self.falhas_ip = {}                             # ip -> [tempos de falha]
        self.ultimo_salvar = 0

    # ---------- protecao contra senha chutada ----------
    def bloqueado(self, ip):
        agora = time.time()
        lista = [t for t in self.falhas_ip.get(ip, []) if agora - t < 900]
        self.falhas_ip[ip] = lista
        return len(lista) >= 10

    def registrar_falha(self, ip):
        self.falhas_ip.setdefault(ip, []).append(time.time())

    def autenticar_tecnico(self, msg, ip):
        if self.bloqueado(ip):
            return None, "Muitas tentativas erradas. Aguarde 15 minutos."
        usuario = str(msg.get("usuario", "")).strip().lower()
        senha = str(msg.get("senha", ""))
        tecnicos = ler_json(ARQ_TECNICOS, {})
        t = tecnicos.get(usuario)
        if not t or not confere_senha(senha, t.get("senha", "")):
            self.registrar_falha(ip)
            log("Login de tecnico recusado: '%s' (%s)" % (usuario, ip))
            return None, "Usuário ou senha incorretos."
        return t.get("nome") or usuario, None

    def salvar_db(self, forcar=False):
        if forcar or time.time() - self.ultimo_salvar > 30:
            gravar_json(ARQ_IDS, self.clientes_db)
            self.ultimo_salvar = time.time()

    def novo_id(self):
        while True:
            cid = str(secrets.randbelow(900000000) + 100000000)  # 9 digitos
            if cid not in self.clientes_db:
                return cid

    # ---------- conexoes ----------
    async def atender(self, reader, writer):
        ip = "?"
        try:
            peer = writer.get_extra_info("peername")
            if peer:
                ip = peer[0]
            linha = await asyncio.wait_for(reader.readline(), TEMPO_LINHA)
            if not linha or len(linha) > MAX_LINHA:
                return
            msg = json.loads(linha.decode("utf-8"))
            tipo = msg.get("tipo")
            if tipo == "registrar":
                await self.cliente_conectado(msg, reader, writer, ip)
            elif tipo == "sessao":
                await self.sessao_do_cliente(msg, reader, writer)
            elif tipo == "login":
                nome, erro = self.autenticar_tecnico(msg, ip)
                await responder(writer, {"ok": nome is not None, "nome": nome or "", "erro": erro or ""})
            elif tipo == "status":
                nome, erro = self.autenticar_tecnico(msg, ip)
                if nome is None:
                    await responder(writer, {"ok": False, "erro": erro})
                else:
                    ids = [str(x) for x in msg.get("ids", [])][:500]
                    on = [i for i in ids if i in self.online]
                    await responder(writer, {"ok": True, "online": on})
            elif tipo == "conectar":
                await self.tecnico_conectar(msg, reader, writer, ip)
            elif tipo == "historico":
                nome, erro = self.autenticar_tecnico(msg, ip)
                if nome is None:
                    await responder(writer, {"ok": False, "erro": erro})
                else:
                    cid = "".join(ch for ch in str(msg.get("id", "")) if ch.isdigit())
                    try:
                        limite = max(1, min(3000, int(msg.get("limite", 500))))
                    except (TypeError, ValueError):
                        limite = 500
                    await responder(writer, {"ok": True, "itens": ler_historico(cid, limite)})
            elif tipo == "evento":
                # o programa do tecnico informa o que aconteceu dentro da sessao
                nome, erro = self.autenticar_tecnico(msg, ip)
                if nome is None:
                    await responder(writer, {"ok": False, "erro": erro})
                else:
                    ev = str(msg.get("evento", ""))
                    if ev in ("arquivo_enviado", "arquivo_baixado", "senha_incorreta"):
                        cid = "".join(ch for ch in str(msg.get("id", "")) if ch.isdigit())
                        reg = self.clientes_db.get(cid, {})
                        historico(ev, nome, cid, reg.get("nome", ""), detalhe=str(msg.get("detalhe", "")))
                    await responder(writer, {"ok": True})
        except (asyncio.TimeoutError, ConnectionError, ssl.SSLError, ValueError, UnicodeDecodeError):
            pass
        except Exception as e:
            log("Erro inesperado (%s): %r" % (ip, e))
        finally:
            fechar(writer)

    async def cliente_conectado(self, msg, reader, writer, ip):
        cid = str(msg.get("id", "")).strip()
        token = str(msg.get("token", ""))
        nome = str(msg.get("nome", ""))[:64]
        modo = str(msg.get("modo", ""))[:20]

        reg = self.clientes_db.get(cid)
        if not cid or not reg or not hmac.compare_digest(reg.get("token", ""), sha(token)):
            # ID novo (ou identidade invalida): gera outro
            cid = self.novo_id()
            token = secrets.token_hex(24)
            self.clientes_db[cid] = {"token": sha(token), "nome": nome,
                                     "criado": time.strftime("%d/%m/%Y %H:%M")}
            log("Novo ID criado: %s (%s, %s)" % (cid, nome, ip))
        reg = self.clientes_db[cid]
        reg["nome"] = nome
        reg["visto"] = time.strftime("%d/%m/%Y %H:%M")
        reg["modo"] = modo
        self.salvar_db(forcar=True)

        antigo = self.online.get(cid)
        if antigo:
            fechar(antigo.writer)

        c = Cliente(cid, nome, modo, writer, ip)
        self.online[cid] = c
        await c.enviar({"tipo": "registrado", "id": cid, "token": token})
        try:
            while True:
                linha = await asyncio.wait_for(reader.readline(), TEMPO_SEM_PING)
                if not linha:
                    break
                c.ultimo_sinal = time.time()
                try:
                    m = json.loads(linha.decode("utf-8"))
                except ValueError:
                    continue
                t = m.get("tipo")
                if t == "ping":
                    await c.enviar({"tipo": "pong"})
                elif t in ("recusado", "ocupado", "bloqueado"):
                    fut = self.pendentes.get(str(m.get("sessao", "")))
                    if fut and not fut.done():
                        fut.set_result(t)
        finally:
            if self.online.get(cid) is c:
                del self.online[cid]

    async def sessao_do_cliente(self, msg, reader, writer):
        fut = self.pendentes.get(str(msg.get("sessao", "")))
        if not fut or fut.done():
            return
        terminou = asyncio.get_event_loop().create_future()
        fut.set_result((reader, writer, terminou))
        await terminou   # mantem a conexao aberta ate o fim da sessao

    async def tecnico_conectar(self, msg, reader, writer, ip):
        nome, erro = self.autenticar_tecnico(msg, ip)
        if nome is None:
            await responder(writer, {"ok": False, "erro": erro})
            return
        cid = "".join(ch for ch in str(msg.get("id", "")) if ch.isdigit())
        c = self.online.get(cid)
        if not c:
            await responder(writer, {"ok": False, "erro": "O ID %s não está online." % cid})
            return

        sessao = secrets.token_hex(16)
        fut = asyncio.get_event_loop().create_future()
        self.pendentes[sessao] = fut
        try:
            await c.enviar({"tipo": "pedido", "sessao": sessao, "tecnico": nome})
            try:
                res = await asyncio.wait_for(fut, TEMPO_PERMISSAO)
            except asyncio.TimeoutError:
                res = "tempo"
        finally:
            self.pendentes.pop(sessao, None)

        if not isinstance(res, tuple):
            erros = {"recusado": "O cliente recusou o acesso.",
                     "ocupado": "O cliente já está em outro atendimento.",
                     "bloqueado": "Acesso bloqueado temporariamente por excesso de senhas erradas.",
                     "tempo": "O cliente não respondeu ao pedido de acesso."}
            await responder(writer, {"ok": False, "erro": erros.get(res, "Acesso não realizado.")})
            log("Acesso NAO realizado: tecnico %s -> ID %s (%s)" % (nome, cid, res))
            historico(res if res in erros else "nao_realizado", nome, cid, c.nome)
            return

        r2, w2, terminou = res
        await responder(writer, {"ok": True})
        log("Sessao INICIADA: tecnico %s (%s) -> ID %s (%s)" % (nome, ip, cid, c.nome))
        inicio = time.time()
        bytes_ = [0, 0]
        try:
            a = asyncio.ensure_future(copiar(reader, w2, bytes_, 0))
            b = asyncio.ensure_future(copiar(r2, writer, bytes_, 1))
            await asyncio.wait([a, b], return_when=asyncio.FIRST_COMPLETED)
            a.cancel()
            b.cancel()
        finally:
            fechar(w2)
            if not terminou.done():
                terminou.set_result(True)
            dur = int(time.time() - inicio)
            historico("acesso", nome, cid, c.nome, dur, (bytes_[0] + bytes_[1]) / 1048576.0,
                      "Modo " + (c.modo or "?"))
            log("Sessao ENCERRADA: tecnico %s -> ID %s  (%dmin %02ds, %.1f MB)" %
                (nome, cid, dur // 60, dur % 60, (bytes_[0] + bytes_[1]) / 1048576.0))


async def responder(writer, obj):
    writer.write((json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8"))
    await writer.drain()


async def copiar(reader, writer, contador, i):
    try:
        while True:
            dados = await reader.read(65536)
            if not dados:
                break
            contador[i] += len(dados)
            writer.write(dados)
            await writer.drain()
    except Exception:
        pass


def fechar(writer):
    try:
        writer.close()
    except Exception:
        pass


# ----------------------------------------------------------------------
#  Comandos
# ----------------------------------------------------------------------
def servir():
    conf = ler_conf()
    porta = int(conf["porta"])
    cert = conf["certificado"]
    chave = conf["chave"]
    if not os.path.isabs(cert):
        cert = os.path.join(PASTA, cert)
    if not os.path.isabs(chave):
        chave = os.path.join(PASTA, chave)

    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    if hasattr(ssl, "TLSVersion"):
        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
    else:
        ctx.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
    ctx.load_cert_chain(cert, chave)

    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    central = Central()
    servidor = loop.run_until_complete(
        asyncio.start_server(central.atender, host=None, port=porta, ssl=ctx, limit=MAX_LINHA * 2))
    log("Central DFCode iniciada na porta %d" % porta)

    async def manutencao():
        while True:
            await asyncio.sleep(30)
            central.salvar_db()

    asyncio.ensure_future(manutencao())
    try:
        loop.run_forever()
    except KeyboardInterrupt:
        pass
    finally:
        central.salvar_db(forcar=True)
        servidor.close()


def adicionar_tecnico(usuario):
    usuario = usuario.strip().lower()
    if not usuario:
        print("Informe o nome de usuario.")
        return
    nome = input("Nome para exibir aos clientes [%s]: " % usuario).strip() or usuario
    while True:
        s1 = getpass.getpass("Senha do tecnico (minimo 8 caracteres): ")
        if len(s1) < 8:
            print("Senha muito curta.")
            continue
        if s1 != getpass.getpass("Repita a senha: "):
            print("As senhas nao conferem.")
            continue
        break
    tecnicos = ler_json(ARQ_TECNICOS, {})
    tecnicos[usuario] = {"nome": nome, "senha": hash_senha(s1)}
    gravar_json(ARQ_TECNICOS, tecnicos)
    print("Tecnico '%s' salvo." % usuario)


def main():
    if len(sys.argv) < 2:
        print(__doc__)
        return
    cmd = sys.argv[1]
    if cmd == "servir":
        servir()
    elif cmd == "adicionar-tecnico" and len(sys.argv) > 2:
        adicionar_tecnico(sys.argv[2])
    elif cmd == "remover-tecnico" and len(sys.argv) > 2:
        t = ler_json(ARQ_TECNICOS, {})
        if t.pop(sys.argv[2].strip().lower(), None) is not None:
            gravar_json(ARQ_TECNICOS, t)
            print("Removido.")
        else:
            print("Tecnico nao encontrado.")
    elif cmd == "tecnicos":
        for u, d in sorted(ler_json(ARQ_TECNICOS, {}).items()):
            print("%-20s %s" % (u, d.get("nome", "")))
    elif cmd == "clientes":
        for i, d in sorted(ler_json(ARQ_IDS, {}).items()):
            print("%s  %-25s visto: %-17s %s" % (i, d.get("nome", ""), d.get("visto", ""), d.get("modo", "")))
    else:
        print(__doc__)


if __name__ == "__main__":
    main()
