"""
Cria um projeto e uma chave por empresa, pela API do Contextia.

    export CONTEXTIA_TENANT_KEY=ctx_...        # chave de TENANT (nao a de projeto)
    python3.8 provisionar.py --mapping=mappings/<cliente>.json --source=generic --dry-run
    python3.8 provisionar.py --mapping=mappings/<cliente>.json --source=generic

Existe porque numa base multi-empresa cada empresa vira um projeto, e o intake
so aceita chave de PROJETO -- nao ha como carregar 24 empresas com uma chave so.
Antes isso exigia estar no servidor rodando `php bin/mcp`; a API faz o mesmo da
maquina de extracao.

O QUE PROTEGE A CHAVE
---------------------
A chave em texto puro aparece UMA vez, no `key.api_key` da resposta. Por isso
ela e gravada no arquivo ANTES de qualquer outra coisa acontecer: um erro depois
da criacao deixaria uma chave orfa, viva no servidor e perdida para nos.

O arquivo e reaproveitado: empresa que ja tem chave nele e pulada. Rodar duas
vezes nao cria chave duplicada, e uma execucao interrompida continua de onde
parou.

WEBHOOK -- a URL e DO CLIENTE
-----------------------------
Atencao ao que esta URL e: **um endpoint do cliente**, no sistema dele. Nao e a
API do Contextia, nao e nada nosso. O cliente publica uma URL HTTPS, e nos
entregamos ali o token de cada projeto assim que ele nasce, para que ele guarde
onde quiser -- cofre, banco, o proprio ERP.

    export CONTEXTIA_WEBHOOK_URL=https://api.docliente.com.br/contextia/tokens
    export CONTEXTIA_WEBHOOK_SECRET=...        # assina o corpo (HMAC-SHA256)

O corpo leva as duas coisas de que ele precisa para ligar uma na outra: o **ID de
identificacao** (o valor do escopo -- o mesmo EMPRESA_ID que ele ja usa no ERP) e
o **token gerado**. Sem o ID, o token chega sem dizer de quem e.

Como o texto puro existe uma vez so, aqui e o unico ponto onde a entrega e
possivel.

A entrega acontece DEPOIS da gravacao em disco, nunca antes: o arquivo e a fonte
de verdade, e o webhook e so transporte. Se a entrega falhar, a chave NAO se
perde -- ela ja esta no arquivo, e `--reenviar-webhook` a manda de novo.
"""

import argparse
import hashlib
import hmac
import io
import json
import os
import re
import sys
import time
import unicodedata

import requests


import regra_projeto

# Fonte unica do endereco da instalacao: ver CONTEXTIA_URL em
# contextia_client.py. O Contextia e instalado por URL, e cada cliente tem a sua.
from contextia_client import L_API_BASE as L_BASE
# Sem padrao apontando para um cliente: o arquivo de chaves e da implantacao.
L_ARQUIVO = os.environ.get('CONTEXTIA_ARQUIVO_CHAVES', './chaves.tsv')

# A API exige slug canonico: minusculas, digitos e hifens simples.
SLUG_VALIDO = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$')


def slug_de(p_nome, p_escopo):
    """Slug canonico, sempre sufixado pelo escopo.

    O sufixo nao e enfeite: razao social repete -- numa base real havia DOIS
    pares de cadastros homonimos. Sem ele os dois
    disputariam o mesmo projeto e a segunda carga cairia dentro da primeira.
    """
    loc = unicodedata.normalize('NFKD', p_nome or '').encode('ascii', 'ignore').decode()
    loc = re.sub(r'[^a-zA-Z0-9]+', '-', loc).strip('-').lower()[:32].strip('-')
    return '%s-%s' % (loc or 'empresa', p_escopo)


def ler_chaves(p_path):
    """Devolve {escopo: chave} do que ja foi provisionado."""
    if not os.path.exists(p_path):
        return {}
    loc = {}
    with io.open(p_path, encoding='utf-8') as f:
        for linha in f:
            if linha.startswith('#') or not linha.strip():
                continue
            partes = linha.split('\t')
            if len(partes) >= 2 and partes[1].strip().startswith('ctx_'):
                loc[partes[0].strip()] = partes[1].strip()
    return loc


def gravar_chave(p_path, p_escopo, p_chave, p_slug, p_nome):
    """Acrescenta UMA chave e devolve so quando ela esta no disco."""
    novo = not os.path.exists(p_path)
    with io.open(p_path, 'a', encoding='utf-8') as f:
        if novo:
            f.write('# EMPRESA_ID\tCHAVE\tPROJETO\tRAZAO SOCIAL\n')
            f.write('# Gerado por provisionar.py. Contem segredo: nao versionar.\n')
        f.write('%s\t%s\t%s\t%s\n' % (p_escopo, p_chave, p_slug, (p_nome or '').replace('\t', ' ')))
        f.flush()
        os.fsync(f.fileno())
    os.chmod(p_path, 0o600)


L_WEBHOOK = os.environ.get('CONTEXTIA_WEBHOOK_URL')
L_WEBHOOK_SEGREDO = os.environ.get('CONTEXTIA_WEBHOOK_SECRET')


def entregar_webhook(p_evento, p_dados):
    """
    POST assinado com o token recem-criado. Devolve (entregue, motivo).

    Tres regras que fazem isso ser seguro, e sem as quais nao vale a pena:

      * **So HTTPS.** O corpo carrega uma credencial viva. Em HTTP ela viaja em
        claro, e quem estiver no caminho ganha acesso ao projeto do cliente.
      * **Assinado.** Sem assinatura, quem descobrir a URL recebe tokens -- e,
        pior, qualquer um pode POSTar tokens falsos e envenenar o cofre do
        cliente. A assinatura e HMAC-SHA256 do corpo exato.
      * **Nunca antes do disco.** A chave ja esta gravada quando isto roda. Uma
        entrega que falha vira pendencia, nunca perda.
    """
    if not L_WEBHOOK:
        return None, 'webhook nao configurado'

    if not L_WEBHOOK.lower().startswith('https://'):
        return False, ('recusado: a URL nao e HTTPS. O corpo carrega uma chave viva;'
                       ' em HTTP ela viaja em claro.')

    corpo = json.dumps({'event': p_evento, 'data': p_dados},
                       ensure_ascii=False, sort_keys=True).encode('utf-8')
    cabecalhos = {'Content-Type': 'application/json'}

    if L_WEBHOOK_SEGREDO:
        assinatura = hmac.new(L_WEBHOOK_SEGREDO.encode('utf-8'), corpo, hashlib.sha256)
        cabecalhos['X-Contextia-Signature'] = 'sha256=' + assinatura.hexdigest()
    # Sem segredo a entrega ainda acontece, mas o operador precisa saber que o
    # destinatario nao tem como distinguir o nosso POST do de um estranho.

    ultimo = ''
    for tentativa in range(3):
        try:
            r = requests.post(L_WEBHOOK, data=corpo, headers=cabecalhos, timeout=30)
            if 200 <= r.status_code < 300:
                return True, 'HTTP %s' % r.status_code
            ultimo = 'HTTP %s %s' % (r.status_code, r.text[:120])
        except Exception as e:  # noqa: BLE001
            ultimo = '%s: %s' % (type(e).__name__, e)
        if tentativa < 2:
            time.sleep(2 ** tentativa)
    return False, ultimo


def api(p_metodo, p_caminho, p_corpo=None):
    chave = os.environ.get('CONTEXTIA_TENANT_KEY')
    if not chave:
        raise SystemExit(
            'Falta CONTEXTIA_TENANT_KEY. Tem de ser a chave de TENANT -- a de projeto\n'
            'recebe 403 tenant_scope_required, porque so age dentro do proprio projeto.'
        )
    r = requests.request(
        p_metodo, L_BASE + p_caminho, timeout=60,
        headers={'Authorization': 'Bearer ' + chave, 'Content-Type': 'application/json'},
        data=json.dumps(p_corpo) if p_corpo is not None else None,
    )
    try:
        corpo = r.json()
    except ValueError:
        corpo = {'_texto': r.text[:400]}
    return r.status_code, corpo


def main():
    p = argparse.ArgumentParser(description='Cria projeto e chave por empresa, pela API.')
    p.add_argument('--mapping', required=True)
    p.add_argument('--source', required=True, help='modulo em sources/ (le a lista de escopos)')
    p.add_argument('--arquivo', default=L_ARQUIVO, help='onde gravar as chaves (padrao: %s)' % L_ARQUIVO)
    p.add_argument('--dry-run', action='store_true', help='mostra o que faria, sem criar nada')
    # O grupo de empresas a que cada projeto criado passa a pertencer.
    #
    # Vai JUNTO da criacao, e nao numa segunda chamada: empresa nova nasce
    # sozinha na recarga da madrugada, e um segundo passo que ninguem executa
    # deixaria a empresa fora do grupo sem nada acusando -- o usuario do cliente
    # simplesmente nao a veria, e ninguem ligaria uma coisa a outra.
    #
    # Pode repetir para mais de um grupo (a holding E a carteira do analista).
    p.add_argument('--grupo', action='append', default=[], metavar='SLUG',
                   help='grupo de empresas do projeto criado; repita para mais de um')
    # O SUPERIOR na hierarquia, no mesmo formato do --grupo e pelo mesmo motivo:
    # numa rede de revenda a unidade nasce de madrugada, e um segundo passo que
    # ninguem executa a deixaria invisivel para quem a supervisiona.
    #
    # E flag de linha de comando e nao campo do mapeamento porque o mapeamento
    # ainda nao sabe LER o superior da origem: a hierarquia costuma morar numa
    # tabela de relacionamento, nao numa coluna do cadastro. Enquanto isso, quem
    # provisiona um lote sob a mesma revenda passa o slug aqui.
    p.add_argument('--superior', action='append', default=[], metavar='SLUG',
                   help='projeto SUPERIOR na hierarquia; o superior passa a alcancar'
                        ' o que for criado. Repita para mais de um')
    # UMA empresa, em vez da fila inteira.
    #
    # Existe para o ouvinte (ouvinte.py): com `projects.group_column`, os grupos
    # de empresas saem do CADASTRO e diferem de uma para outra, entao nao da
    # para criar as pendentes todas numa chamada com os mesmos `--grupo`.
    # Tambem serve a mao, para refazer uma empresa que falhou sem mexer nas
    # outras. Repita para mais de uma.
    p.add_argument('--somente-escopo', action='append', default=[], metavar='VALOR',
                   help='provisiona so este escopo; repita para mais de um')
    p.add_argument('--reenviar-webhook', action='store_true',
                   help='nao cria nada; reenvia ao webhook as chaves que ja estao no arquivo')
    args = p.parse_args()

    sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'sources'))
    import importlib
    origem = importlib.import_module(args.source)

    mapa = json.load(io.open(args.mapping, encoding='utf-8'))
    excluidos = (mapa.get('source') or {}).get('excluded_scopes') or {}

    # O QUE VIRA PROJETO -- declarado no bloco `projects` do mapeamento, ou
    # inferido como antes (o dataset com `scope_column: "ID"`) quando o bloco
    # nao existe. Mapeamento em producao nao muda de comportamento.
    regra = regra_projeto.resolver(mapa)
    coluna_escopo = regra['scope_column']

    linhas = origem.fetch(regra['source_object'], regra['columns'])

    ja = ler_chaves(args.arquivo)

    # Valvula de seguranca: a chave existe em texto puro so na criacao, mas o
    # ARQUIVO a guarda. Se o webhook estava fora do ar naquele momento, da para
    # reentregar sem criar nada.
    if args.reenviar_webhook:
        if not L_WEBHOOK:
            raise SystemExit('Defina CONTEXTIA_WEBHOOK_URL para reenviar.')
        print('Reenviando %d chave(s) para %s\n' % (len(ja), L_WEBHOOK))
        falhas = 0
        for escopo, chave in sorted(ja.items()):
            ok, motivo = entregar_webhook('project.key.created',
                                          {'scope_value': escopo, 'api_key': chave,
                                           'redelivery': True})  # sem os ids: o arquivo nao os guarda
            print('  %-8s %s' % (escopo, 'entregue (%s)' % motivo if ok else 'FALHOU: %s' % motivo))
            falhas += 0 if ok else 1
        print('\nfalhas: %d' % falhas)
        return 1 if falhas else 0
    print('Arquivo de chaves: %s (%d ja provisionadas)' % (args.arquivo, len(ja)))
    print('Empresas na origem: %d | marcadas como teste: %d\n' % (len(linhas), len(excluidos)))

    criados = pulados = 0
    nao_entregues = []
    for linha in linhas:
        escopo = regra_projeto.escopo_de(linha, regra)
        nome = regra_projeto.nome_de(linha, regra)

        if args.somente_escopo and escopo not in args.somente_escopo:
            continue

        if escopo in excluidos:
            print('  %-7s PULADA (teste): %s' % (escopo, excluidos[escopo]))
            pulados += 1
            continue
        if escopo in ja:
            print('  %-7s ja tem chave (%s...)' % (escopo, ja[escopo][:12]))
            pulados += 1
            continue

        # A REGRA, quando o mapeamento a declara: `include_when` e
        # `exclude_when`. Sem o bloco `projects` isto so recusa linha sem
        # identificador -- antes ela viraria um projeto chamado "empresa-none".
        #
        # DEPOIS de `ja`, e nao antes: empresa que virou inativa depois de
        # provisionada nao volta atras -- o projeto e a chave dela existem, e o
        # dado continua sendo dela. Avisar "PULADA pela regra" toda noite sobre
        # uma empresa que ja esta no ar so treinaria o operador a ignorar o log.
        ok, motivo = regra_projeto.aceita(linha, regra)
        if not ok:
            print('  %-7s PULADA pela regra: %s' % (escopo or '?', motivo))
            pulados += 1
            continue

        slug = slug_de(nome, escopo)
        if not SLUG_VALIDO.match(slug):
            print('  %-7s SLUG INVALIDO %r -- pulada' % (escopo, slug))
            pulados += 1
            continue

        if args.dry_run:
            grupos = args.grupo or regra_projeto.grupos_de(linha, regra)
            print('  %-7s criaria projeto %-40s (%s)%s%s%s' % (
                escopo, slug, nome[:34],
                ('  kind=' + regra['kind']) if regra['kind'] else '',
                ('  grupos=' + ','.join(grupos)) if grupos else '',
                ('  superior=' + ','.join(args.superior)) if args.superior else ''))
            criados += 1
            continue

        corpo_projeto = {'name': nome or ('Empresa ' + escopo)}

        # `kind` declarado na regra. Omitir cria `client`, que e o que todo
        # projeto sempre foi -- por isso so vai quando foi pedido.
        if regra['kind']:
            corpo_projeto['kind'] = regra['kind']

        # Ausente PRESERVA os grupos que o projeto ja tem; presente define
        # exatamente estes. Por isso so mandamos quando ha o que mandar -- uma
        # reexecucao sem grupo nenhum nao pode tirar a empresa dos grupos dela.
        #
        # A linha de comando VENCE a regra: quem digitou --grupo esta olhando a
        # tela agora, e o mapeamento foi escrito semanas atras.
        grupos = args.grupo or regra_projeto.grupos_de(linha, regra)
        if grupos:
            corpo_projeto['groups'] = grupos

        # Ausente PRESERVA, igual ao `groups`: uma reexecucao sem --superior nao
        # pode desprender o projeto de quem o supervisiona.
        if args.superior:
            corpo_projeto['superiors'] = args.superior

        # PUT e idempotente: cria na primeira vez, atualiza na repeticao. O POST
        # devolveria 409 numa reexecucao e abortaria o que falta.
        st, corpo = api('PUT', '/projects/' + slug, corpo_projeto)
        if st not in (200, 201):
            print('  %-7s FALHOU ao criar projeto: HTTP %s %s' % (escopo, st, json.dumps(corpo)[:160]))
            continue
        id_projeto = ((corpo or {}).get('project') or {}).get('id')

        st, corpo = api('POST', '/projects/%s/keys' % slug, {'name': 'conector %s' % escopo})
        bloco = (corpo or {}).get('key') or {}
        chave, id_chave = bloco.get('api_key'), bloco.get('id')
        if st != 201 or not chave:
            print('  %-7s FALHOU ao criar chave: HTTP %s %s' % (escopo, st, json.dumps(corpo)[:160]))
            continue

        # Grava ANTES de qualquer outra coisa: a chave so existe em texto aqui.
        gravar_chave(args.arquivo, escopo, chave, slug, nome)
        print('  %-7s OK  projeto=%-40s chave=%s...' % (escopo, slug, chave[:12]))
        criados += 1

        # So depois de estar em disco. Falha aqui e pendencia de entrega, nao
        # perda: a chave esta no arquivo e `--reenviar-webhook` a manda de novo.
        entregue, motivo = entregar_webhook('project.key.created', {
            # O que identifica o registro no sistema DO CLIENTE. E por este valor
            # que ele liga o token a empresa dele -- sem isso o token chega
            # anonimo.
            'scope_value': escopo,
            'scope_column': coluna_escopo,
            'scope_name': nome,
            # O token gerado, em texto puro. So existe aqui.
            'api_key': chave,
            'key_id': id_chave,
            'key_prefix': chave[:12],
            # Onde ele foi parar no Contextia, para diagnostico.
            'project_id': id_projeto,
            'project_slug': slug,
            'project_name': nome,
        })
        if entregue is False:
            print('  %-7s WEBHOOK FALHOU: %s' % (escopo, motivo))
            print('  %-7s   a chave esta salva. Reenvie com --reenviar-webhook.' % '')
            nao_entregues.append(escopo)
        elif entregue:
            print('  %-7s   webhook entregue (%s)' % ('', motivo))

    print('\n%s: %d | pulados: %d' % ('criaria' if args.dry_run else 'criados', criados, pulados))

    if L_WEBHOOK and not args.dry_run:
        print('Webhook ...: %s%s' % (L_WEBHOOK,
              '' if L_WEBHOOK_SEGREDO else '  (SEM SEGREDO: o destinatario nao'
              ' consegue distinguir o nosso POST do de um estranho)'))
    if nao_entregues:
        print('NAO ENTREGUES ao webhook: %s' % ', '.join(nao_entregues))
        print('As chaves estao salvas. Reenvie com:')
        print('  python3 provisionar.py --mapping=... --source=... --arquivo=%s --reenviar-webhook'
              % args.arquivo)
    if not args.dry_run and criados:
        print('Chaves em %s (permissao 600). Carregue com:' % args.arquivo)
        print('  ./carregar-todas.sh %s' % args.arquivo)

    if hasattr(origem, 'fechar'):
        origem.fechar()
    # Entrega pendente e falha: o cliente esperava o token e nao recebeu.
    return 1 if nao_entregues else 0


if __name__ == '__main__':
    sys.exit(main())
