"""
A REGRA de o que vira um projeto no Contextia -- declarada, nao adivinhada.

    from regra_projeto import resolver, aceita
    regra = resolver(mapa)
    for linha in origem.fetch(regra['source_object'], regra['columns']):
        ok, motivo = aceita(linha, regra)

Antes esta regra era ADIVINHADA dentro do provisionador: o dataset com
`scope_column: "ID"` era o cadastro de empresas, e a coluna de nome era a
primeira que tivesse "RAZAO" ou "NOME" no nome. Funciona, e funciona por sorte:
numa base sem nenhuma das duas palavras o projeto nasce chamado "Empresa 13781",
e numa base com duas views de empresa a escolhida e a que vier primeiro no
arquivo.

Pior: adivinhacao nao tem como expressar o que a operacao real pede --
*"so empresa ativa"*, *"cadastro de teste nao"*, *"esta coluna diz de que
holding a empresa e"*. Isso precisa estar ESCRITO, no mapeamento, onde o
cliente pode ler e discordar.

O BLOCO NO MAPEAMENTO
---------------------
Opcional. Mapeamento sem ele continua funcionando exatamente como antes -- a
adivinhacao vira o modo de compatibilidade, e `origem_da_regra` diz qual dos
dois esta valendo.

    "projects": {
      "source_object": "VW_EMPRESAS",
      "scope_column":  "ID",
      "name_column":   "RAZAOSOCIAL",
      "columns":       ["ID", "RAZAOSOCIAL", "ATIVO", "TIPO", "HOLDING"],
      "watch_column":  "DT_CADASTRO",
      "include_when":  {"ATIVO": ["S"]},
      "exclude_when":  {"TIPO": ["T", "DEMO"]},
      "kind":          "client",
      "groups":        ["holding-alfa"],
      "group_column":  "HOLDING",
      "group_prefix":  "holding-"
    }

SEM LOGICA DENTRO, e isso e uma regra do produto e nao um detalhe deste arquivo.
`include_when` e `exclude_when` sao mapas de coluna para LISTA DE VALORES, nunca
expressoes: `{"ATIVO": ["S"]}` significa "a coluna ATIVO tem de ser S". Nao ha
`>`, nao ha `or`, nao ha SQL. No dia em que o mapeamento virar a configuracao do
agente instalado, um `if` escrito ali seria codigo do cliente rodando na nossa
casa.

Quando a regra nao couber nesse formato -- e vai acontecer --, o lugar de
resolver e a VIEW: peca ao cliente uma `VW_EMPRESAS_CONTEXTIA` com a regra
dentro, e aponte `source_object` para ela. A regra fica onde o cliente a
mantem, e nos continuamos so lendo.

POR QUE `exclude_when` NAO SUBSTITUI `excluded_scopes`
------------------------------------------------------
Sao coisas diferentes e as duas continuam existindo:

    exclude_when      regra ESTAVEL, por coluna. Vale para empresa que ainda
                      nao existe -- e por isso nao envelhece.
    excluded_scopes   lista de VALORES, um a um. Vale para o cadastro especifico
                      que alguem conferiu e decidiu manter fora.

A lista de valores e a que envelhece: um cadastro de teste criado depois dela
entra como cliente de verdade. Quando existir uma coluna que distinga teste de
producao, `exclude_when` e melhor -- e nesse caso vale escrever no registro do
cliente que a lista de valores deixou de ser necessaria.
"""

import re


# O mesmo identificador que o adaptador de origem aceita concatenar no SQL. Um
# nome de coluna que passa daqui e recusado la adiantaria pouco: o erro sairia
# no meio de uma carga, e nao na leitura do mapeamento.
IDENTIFICADOR = re.compile(r'^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$')

# Os dois unicos tipos de projeto que a API conhece. `client` e o que todo
# projeto sempre foi, e continua sendo o padrao: omitir o campo na criacao cria
# cliente.
KINDS = ('client', 'source')


def _exigir_identificador(p_valor, p_onde):
    if not p_valor or not IDENTIFICADOR.match(str(p_valor)):
        raise SystemExit(
            'projects.%s = %r nao e um nome de coluna/objeto valido.'
            % (p_onde, p_valor)
        )
    return str(p_valor)


def _lista_de_valores(p_bloco, p_nome):
    """
    Normaliza `include_when` / `exclude_when` para {coluna: [valores como texto]}.

    Aceita valor solto (`{"ATIVO": "S"}`) porque e o que a pessoa escreve na
    primeira vez, e recusa qualquer coisa que nao seja escalar ou lista de
    escalares -- um dicionario aqui seria alguem tentando escrever expressao.
    """
    loc_bruto = p_bloco.get(p_nome) or {}
    if not isinstance(loc_bruto, dict):
        raise SystemExit('projects.%s tem de ser um objeto {coluna: valores}.' % p_nome)

    loc_saida = {}
    for coluna, valores in loc_bruto.items():
        _exigir_identificador(coluna, '%s.%s' % (p_nome, coluna))

        if isinstance(valores, (list, tuple)):
            loc_itens = list(valores)
        else:
            loc_itens = [valores]

        for item in loc_itens:
            if isinstance(item, (dict, list, tuple)):
                raise SystemExit(
                    'projects.%s.%s: valor %r nao e escalar. Este bloco compara'
                    ' IGUALDADE com uma lista de valores; nao ha expressao aqui.'
                    ' Regra que nao couber nesse formato vai para uma view do'
                    ' cliente, e `source_object` aponta para ela.'
                    % (p_nome, coluna, item)
                )

        # Comparacao como TEXTO, sempre. A origem devolve 1 (int) onde o
        # mapeamento escreveu "1" (texto) com frequencia -- e um filtro que
        # falha por tipo nao filtra nada, ou filtra tudo, sem avisar.
        loc_saida[coluna] = [_texto(v) for v in loc_itens]

    return loc_saida


def _texto(p_valor):
    """Comparavel: None vira '', o resto vira texto sem espaco nas pontas."""
    if p_valor is None:
        return ''
    if isinstance(p_valor, bool):
        return 'true' if p_valor else 'false'
    return str(p_valor).strip()


# ---------------------------------------------------------------------------
# Modo de compatibilidade: a regra ADIVINHADA
# ---------------------------------------------------------------------------

def _inferir(p_mapa):
    """
    A regra como o provisionador a adivinhava antes deste modulo existir.

    Preservada tal e qual, de proposito: mapeamento que ja esta em producao nao
    pode mudar de comportamento porque o SDK ganhou um recurso novo.
    """
    loc_ds = next(
        (d for d in (p_mapa.get('datasets') or []) if d.get('scope_column') == 'ID'),
        None,
    )
    if loc_ds is None:
        raise SystemExit(
            'O mapeamento nao diz o que e um projeto.\n'
            'Escreva o bloco `projects` (veja regra_projeto.py), ou marque o'
            ' dataset de empresas com `"scope_column": "ID"` -- ali a empresa e'
            ' a LINHA, nao uma coluna.'
        )

    loc_colunas = list(loc_ds['columns'])
    loc_nome = next((c for c in loc_colunas if 'RAZAO' in c or 'NOME' in c), 'ID')

    return {
        'source_object': loc_ds['source_object'],
        'scope_column': 'ID',
        'name_column': loc_nome,
        'columns': loc_colunas,
        'watch_column': None,
        'include_when': {},
        'exclude_when': {},
        'kind': None,          # ausente = a API cria `client`
        'groups': [],
        'group_column': None,
        'group_prefix': '',
        'origem_da_regra': 'inferida',
        'dataset': loc_ds.get('dataset'),
    }


# ---------------------------------------------------------------------------
# A regra declarada
# ---------------------------------------------------------------------------

def resolver(p_mapa):
    """
    Devolve a regra de projeto deste mapeamento, declarada ou inferida.

    Aborta em vez de avisar quando o bloco `projects` existe e esta errado: uma
    regra de projeto meio aplicada cria projeto para quem nao devia, e projeto
    criado com chave entregue ao cliente nao se desfaz sem alguem explicar.
    """
    loc_bloco = p_mapa.get('projects')
    if not loc_bloco:
        return _inferir(p_mapa)

    if not isinstance(loc_bloco, dict):
        raise SystemExit('`projects` tem de ser um objeto.')

    loc_objeto = _exigir_identificador(loc_bloco.get('source_object'), 'source_object')
    loc_escopo = _exigir_identificador(loc_bloco.get('scope_column'), 'scope_column')
    loc_nome = _exigir_identificador(loc_bloco.get('name_column'), 'name_column')

    loc_include = _lista_de_valores(loc_bloco, 'include_when')
    loc_exclude = _lista_de_valores(loc_bloco, 'exclude_when')

    loc_watch = loc_bloco.get('watch_column')
    if loc_watch:
        _exigir_identificador(loc_watch, 'watch_column')

    loc_grupo_col = loc_bloco.get('group_column')
    if loc_grupo_col:
        _exigir_identificador(loc_grupo_col, 'group_column')

    loc_kind = loc_bloco.get('kind')
    if loc_kind is not None and loc_kind not in KINDS:
        raise SystemExit(
            'projects.kind = %r. Os unicos valores sao %s. Omitir cria `client`,'
            ' que e o que todo projeto sempre foi.' % (loc_kind, ' e '.join(KINDS))
        )

    # A projecao precisa conter TODA coluna que a regra le. Faltar uma faria o
    # filtro comparar com vazio e -- pior -- passar em silencio: `include_when`
    # sobre coluna ausente recusaria todas as empresas, e a integracao ficaria
    # sem criar projeto nenhum sem nada acusando.
    loc_colunas = list(loc_bloco.get('columns') or [])
    for coluna in loc_colunas:
        _exigir_identificador(coluna, 'columns')

    loc_precisa = [loc_escopo, loc_nome]
    loc_precisa += list(loc_include) + list(loc_exclude)
    if loc_watch:
        loc_precisa.append(loc_watch)
    if loc_grupo_col:
        loc_precisa.append(loc_grupo_col)

    if not loc_colunas:
        # Sem `columns` declarado, a projecao e exatamente o que a regra usa.
        loc_colunas = []
        for c in loc_precisa:
            if c not in loc_colunas:
                loc_colunas.append(c)
    else:
        loc_faltando = [c for c in loc_precisa if c not in loc_colunas]
        if loc_faltando:
            raise SystemExit(
                'projects.columns nao traz coluna(s) que a propria regra usa: %s.\n'
                'Sem elas na projecao o filtro compararia com vazio e recusaria'
                ' TODAS as empresas -- sem erro nenhum.' % ', '.join(loc_faltando)
            )

    loc_grupos = loc_bloco.get('groups') or []
    if isinstance(loc_grupos, str):
        loc_grupos = [loc_grupos]

    return {
        'source_object': loc_objeto,
        'scope_column': loc_escopo,
        'name_column': loc_nome,
        'columns': loc_colunas,
        'watch_column': loc_watch,
        'include_when': loc_include,
        'exclude_when': loc_exclude,
        'kind': loc_kind,
        'groups': [str(g) for g in loc_grupos],
        'group_column': loc_grupo_col,
        'group_prefix': str(loc_bloco.get('group_prefix') or ''),
        'origem_da_regra': 'projects',
        'dataset': None,
    }


def aceita(p_linha, p_regra):
    """
    Esta linha da origem vira projeto? Devolve (bool, motivo).

    O MOTIVO nao e enfeite: e ele que aparece no log e no `--dry-run`, e e a
    unica forma de alguem entender por que a empresa que ele esperava nao
    nasceu. "recusada" sozinho gera chamado; "recusada: ATIVO=N, esperado S"
    resolve na hora.
    """
    for coluna, esperados in (p_regra['exclude_when'] or {}).items():
        loc_valor = _texto(p_linha.get(coluna))
        if loc_valor in esperados:
            return False, 'exclude_when: %s=%s' % (coluna, loc_valor or '(vazio)')

    for coluna, esperados in (p_regra['include_when'] or {}).items():
        loc_valor = _texto(p_linha.get(coluna))
        if loc_valor not in esperados:
            return False, 'include_when: %s=%s, esperado %s' % (
                coluna, loc_valor or '(vazio)', '|'.join(esperados))

    if not _texto(p_linha.get(p_regra['scope_column'])):
        return False, 'sem valor em %s (a coluna que identifica a empresa)' % p_regra['scope_column']

    return True, ''


def escopo_de(p_linha, p_regra):
    """O valor que identifica esta empresa -- o mesmo que vai no `--scope`."""
    return _texto(p_linha.get(p_regra['scope_column']))


def nome_de(p_linha, p_regra):
    return _texto(p_linha.get(p_regra['name_column']))


def grupos_de(p_linha, p_regra):
    """
    Os grupos de empresas deste projeto: os fixos da regra mais o derivado da
    coluna, quando declarada.

    `group_column` existe porque numa contabilidade a holding e um DADO do
    cadastro, nao uma decisao de quem provisiona. Com ela, empresa nova de uma
    holding ja conhecida nasce no grupo certo na madrugada, sem ninguem editar
    nada -- que e o ponto inteiro de o grupo ir na criacao do projeto.

    Devolve [] quando nada foi declarado, e [] tem significado: quem chama NAO
    manda `groups` no corpo, e o PUT PRESERVA os grupos que o projeto ja tem.
    Mandar lista vazia tiraria a empresa de todos os grupos dela.
    """
    loc_grupos = list(p_regra['groups'] or [])

    loc_coluna = p_regra.get('group_column')
    if loc_coluna:
        loc_valor = _texto(p_linha.get(loc_coluna))
        if loc_valor:
            loc_slug = _slug(p_regra.get('group_prefix', '') + loc_valor)
            if loc_slug and loc_slug not in loc_grupos:
                loc_grupos.append(loc_slug)

    return loc_grupos


def _slug(p_texto):
    """Slug canonico de grupo: minusculas, digitos e hifens simples."""
    import unicodedata
    loc = unicodedata.normalize('NFKD', p_texto or '').encode('ascii', 'ignore').decode()
    loc = re.sub(r'[^a-zA-Z0-9]+', '-', loc).strip('-').lower()[:64]
    return loc.strip('-')


def descrever(p_regra):
    """Uma linha por caracteristica, para o log e o `--dry-run`."""
    loc = []
    loc.append('objeto ........: %s' % p_regra['source_object'])
    loc.append('identifica por : %s' % p_regra['scope_column'])
    loc.append('nome ..........: %s' % p_regra['name_column'])
    loc.append('tipo ..........: %s' % (p_regra['kind'] or 'client (padrao)'))
    loc.append('coluna de mudanca: %s' % (p_regra['watch_column'] or '(nenhuma -- ouvinte nao ve empresa nova ao vivo)'))
    loc.append('inclui so quando : %s' % (p_regra['include_when'] or '(tudo)'))
    loc.append('exclui quando ...: %s' % (p_regra['exclude_when'] or '(nada)'))
    loc.append('grupos ..........: %s%s' % (
        ', '.join(p_regra['groups']) or '(nenhum)',
        ' + coluna %s' % p_regra['group_column'] if p_regra['group_column'] else ''))
    loc.append('origem da regra .: %s' % (
        'bloco `projects` do mapeamento' if p_regra['origem_da_regra'] == 'projects'
        else 'INFERIDA (dataset com scope_column "ID") -- escreva `projects` para tirar a adivinhacao do caminho'))
    return '\n'.join('  ' + x for x in loc)


# ---------------------------------------------------------------------------
# Autoteste -- roda sem banco, sem rede e sem mapeamento de cliente
# ---------------------------------------------------------------------------

def _autoteste():
    """
    `python3 regra_projeto.py` -- prova as regras que nao dao para conferir a olho.

    Nao precisa de banco nem de rede de proposito: e o unico teste do SDK que
    da para rodar na maquina de quem esta escrevendo o mapeamento, antes de
    tocar na origem do cliente.
    """
    loc_falhas = []

    def igual(p_rotulo, p_obtido, p_esperado):
        if p_obtido != p_esperado:
            loc_falhas.append('%s: obtido %r, esperado %r' % (p_rotulo, p_obtido, p_esperado))
            print('  FALHOU  %s' % p_rotulo)
        else:
            print('  ok      %s' % p_rotulo)

    def aborta(p_rotulo, p_funcao):
        try:
            p_funcao()
        except SystemExit:
            print('  ok      %s' % p_rotulo)
            return
        loc_falhas.append('%s: NAO abortou' % p_rotulo)
        print('  FALHOU  %s (nao abortou)' % p_rotulo)

    print('compatibilidade -- mapeamento sem o bloco `projects`')
    loc_antigo = {'datasets': [
        {'dataset': 'empresas', 'source_object': 'VW_EMPRESAS', 'scope_column': 'ID',
         'columns': ['ID', 'RAZAOSOCIAL', 'CNPJ']},
        {'dataset': 'contas', 'source_object': 'VW_CONTAS', 'columns': ['ID']},
    ]}
    loc_r = resolver(loc_antigo)
    igual('acha a view de empresas pelo scope_column ID', loc_r['source_object'], 'VW_EMPRESAS')
    igual('acha a coluna de nome por RAZAO', loc_r['name_column'], 'RAZAOSOCIAL')
    igual('marca a regra como inferida', loc_r['origem_da_regra'], 'inferida')
    igual('sem kind: a API cria client', loc_r['kind'], None)
    igual('sem coluna de mudanca', loc_r['watch_column'], None)
    igual('aceita qualquer linha', aceita({'ID': 13781}, loc_r)[0], True)
    igual('recusa linha sem identificador', aceita({'ID': None}, loc_r)[0], False)

    aborta('mapeamento que nao diz o que e projeto ABORTA',
           lambda: resolver({'datasets': [{'dataset': 'x', 'source_object': 'V', 'columns': ['ID']}]}))

    print('\nregra declarada')
    loc_mapa = {'projects': {
        'source_object': 'VW_EMPRESAS', 'scope_column': 'CODIGO', 'name_column': 'RAZAO',
        'columns': ['CODIGO', 'RAZAO', 'ATIVO', 'TIPO', 'HOLDING'],
        'watch_column': 'DT_CADASTRO',
        'include_when': {'ATIVO': 'S'},
        'exclude_when': {'TIPO': ['T', 'DEMO']},
        'kind': 'client', 'groups': ['carteira-1'],
        'group_column': 'HOLDING', 'group_prefix': 'holding-',
    }}
    aborta('watch_column fora de columns ABORTA', lambda: resolver(loc_mapa))

    loc_mapa['projects']['columns'].append('DT_CADASTRO')
    loc_r = resolver(loc_mapa)
    igual('a regra declarada vence', loc_r['origem_da_regra'], 'projects')
    igual('valor solto virou lista', loc_r['include_when'], {'ATIVO': ['S']})

    loc_viva = {'CODIGO': 13781, 'RAZAO': 'Alfa', 'ATIVO': 'S', 'TIPO': 'C', 'HOLDING': 'Grupo Alfa'}
    igual('empresa ativa entra', aceita(loc_viva, loc_r)[0], True)
    igual('grupo fixo + derivado da coluna',
          grupos_de(loc_viva, loc_r), ['carteira-1', 'holding-grupo-alfa'])
    igual('escopo sai como texto', escopo_de(loc_viva, loc_r), '13781')

    igual('inativa recusada', aceita(dict(loc_viva, ATIVO='N'), loc_r)[0], False)
    igual('e o motivo diz qual coluna',
          'ATIVO' in aceita(dict(loc_viva, ATIVO='N'), loc_r)[1], True)
    igual('tipo excluido recusado', aceita(dict(loc_viva, TIPO='DEMO'), loc_r)[0], False)
    igual('exclude vence include',
          aceita(dict(loc_viva, TIPO='T'), loc_r)[1].startswith('exclude_when'), True)

    # Numero na origem contra texto no mapeamento: o erro que faz um filtro
    # silenciosamente nao filtrar nada.
    loc_r2 = resolver({'projects': {
        'source_object': 'V', 'scope_column': 'ID', 'name_column': 'N',
        'columns': ['ID', 'N', 'ATIVO'], 'include_when': {'ATIVO': 1},
    }})
    igual('1 (numero) casa com "1" (texto)', aceita({'ID': 1, 'N': 'x', 'ATIVO': 1}, loc_r2)[0], True)
    igual('e 0 nao casa', aceita({'ID': 1, 'N': 'x', 'ATIVO': 0}, loc_r2)[0], False)

    print('\nrecusas de mapeamento errado')
    aborta('coluna com aspas/SQL no nome',
           lambda: resolver({'projects': {'source_object': 'V', 'scope_column': "ID; DROP",
                                          'name_column': 'N'}}))
    aborta('kind desconhecido',
           lambda: resolver({'projects': {'source_object': 'V', 'scope_column': 'ID',
                                          'name_column': 'N', 'kind': 'empresa'}}))
    aborta('expressao dentro de include_when',
           lambda: resolver({'projects': {'source_object': 'V', 'scope_column': 'ID',
                                          'name_column': 'N', 'columns': ['ID', 'N', 'X'],
                                          'include_when': {'X': [{'gt': 1}]}}}))
    aborta('include_when sobre coluna fora da projecao',
           lambda: resolver({'projects': {'source_object': 'V', 'scope_column': 'ID',
                                          'name_column': 'N', 'columns': ['ID', 'N'],
                                          'include_when': {'ATIVO': ['S']}}}))

    print()
    if loc_falhas:
        print('%d FALHA(S):' % len(loc_falhas))
        for f in loc_falhas:
            print('  - %s' % f)
        return 1
    print('tudo passou.')
    return 0


if __name__ == '__main__':
    import sys
    sys.exit(_autoteste())
