# -*- coding: utf-8 -*-
"""Le o conteudo do servidor de Data/, em vez do banco.

A divisao: Data/ guarda tudo que vem do client ou foi medido no oficial -- itens, missoes,
monstros, classes, mapas. O banco fica so com o que se perde se sumir: contas, personagens,
inventario, sessao. E o mesmo desenho dos .scp do servidor de Cabal.

Motivo pratico, medido em 24/08: consultar o banco por conteudo a cada evento custava 4,69 ms
com commit, o que dava um teto de 213 eventos por segundo -- abaixo dos 300 que 100 jogadores
geram. Lendo tudo pra memoria uma vez, a busca cai pra 0,88 microssegundo e o banco sai
inteiramente do caminho critico. Sao 12 MB em disco, 13 MB na memoria, 296 ms pra carregar.

Data/Common/ e a parte que o dono do servidor mexe -- multiplicadores de evento. Essa pasta
tem recarga a quente: mudou o arquivo, a proxima leitura pega o valor novo, sem reiniciar.
O resto so recarrega quando o servidor sobe.
"""
import os
import re
import threading

# A barra invertida escrita como chr(92): heredoc de shell a engole, e ja corrompeu
# arquivo neste projeto -- um "b" de limite de palavra virou byte de backspace de verdade.
BARRA = chr(92)
NL = chr(10)
ESCAPES = ((BARRA + "n", chr(10)), (BARRA + "t", chr(9)), (BARRA + BARRA, BARRA))

# Data/ fica DENTRO de server/: o conteudo do jogo viaja junto com o servidor, entao
# copiar a pasta server/ pra outra maquina leva tudo que ele precisa pra rodar.
AQUI = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(AQUI, "Data")
COMMON = os.path.join(DATA, "Common")

_trava = threading.Lock()
_tabelas = {}          # nome -> lista de dicionarios (so depois de lido)
_caminhos = {}         # nome -> caminho no disco, preenchido no carregar()
_indices = {}          # (nome, coluna) -> dicionario
_mtime_common = {}
_faltando = set()          # nomes ja avisados, pra nao repetir o aviso     # caminho -> mtime da ultima leitura


def _converter(s):
    """Texto do arquivo para o tipo que faz sentido.

    Campo vazio vira None, nao string vazia nem zero: a diferenca importa em colunas como
    pre_requisito, onde 0 significa "primeira da cadeia" e vazio significa "nao se aplica".
    """
    if s == "":
        return None
    if s in ("True", "False"):
        return s == "True"
    # So paga o escape quando ha barra invertida: sem esta guarda, tres replace rodavam em
    # cada um dos 670 mil campos e a carga ia de 300 ms pra 3,2 s.
    if BARRA in s:
        for de, para in ESCAPES:
            s = s.replace(de, para)
    c = s[0]
    if c.isdigit() or (c == "-" and len(s) > 1):
        try:
            return int(s)
        except ValueError:
            # SO tenta float se PARECER um numero decimal.
            #
            # O fallback cego destruia dados: a coluna `campos` do Item.ini traz um blob
            # hexadecimal de 328 caracteres, e basta ter um "e" no meio para o float() o ler
            # como notacao cientifica e devolver INFINITO. Trinta e cinco dos 19.806 itens
            # ficavam com o campo perdido, em silencio, desde sempre.
            #
            # Um numero decimal a serio tem ponto, ou e curto. Um blob nao tem ponto e e
            # longo. A regra separa os dois sem tocar em nenhum valor legitimo.
            if "." in s or len(s) <= 20:
                try:
                    return float(s)
                except ValueError:
                    return s
            return s
    return s


_DOIS_ESPACOS = re.compile(chr(32) + "{2,}")
# Marcador de celula vazia nas tabelas alinhadas por espacos. Um til sozinho nao aparece em
# nenhum valor real -- nem em nome de item, nem em numero.
VAZIO = chr(126)


def _separar(linha):
    """Parte uma linha de tabela em celulas.

    O separador e a TABULACAO ou uma corrida de DOIS OU MAIS espacos. Duas formas para a
    mesma coisa porque as tabelas passaram a ser escritas com as colunas alinhadas -- ler
    "1  1002  1-espada pesada  2115" e muito mais facil do que contar tabulacoes -- e um
    unico espaco nao pode separar, senao "1-espada pesada" partia-se em dois.

    Ficheiros antigos, so com tabulacao e sem alinhamento, continuam a ser lidos na mesma:
    a tabulacao e sempre separador, haja ou nao espacos a volta.

    CELULA VAZIA TEM DE TER MARCADOR. Com tabulacao, duas seguidas dizem "aqui nao ha nada";
    com espacos nao dizem nada, e a coluna desaparece -- as seguintes deslizam todas uma casa.
    Foi assim que o Item.ini se partiu em 27/08: a Bengala nao tem `modelo`, e a partir dai
    `slot` passou a ler o que era de `classe_req`, o que fez o servidor recusar equipar tudo.
    Por isso a escrita poe VAZIO onde nao ha valor, e a leitura converte-o de volta.
    """
    if chr(9) in linha:
        return linha.split(chr(9))
    return [("" if c == VAZIO else c) for c in _DOIS_ESPACOS.split(linha)]


def _desescapar(s):
    """Desfaz o escape do formato de colunas: \n, \t, \\" e \\.

    Uma passagem so, da esquerda para a direita. Fazer replace em cadeia estaria errado:
    uma barra escapada seguida de n viraria quebra de linha.
    """
    B = chr(92)
    if B not in s:
        return s
    out = []
    i = 0
    while i < len(s):
        c = s[i]
        if c == B and i + 1 < len(s):
            p = s[i + 1]
            out.append({"n": chr(10), "t": chr(9), chr(34): chr(34), B: B}.get(p, c + p))
            i += 2
        else:
            out.append(c)
            i += 1
    return "".join(out)


def _ler_tsv(caminho):
    """Le uma tabela em COLUNAS, separada por TAB, dentro de um .ini normal.

    POR QUE EXISTE, ao lado do .ini: tamanho. O .ini repete o nome de cada coluna em cada
    registo -- o Item.ini gasta 13,8 MB para 19.806 itens, quase tudo a escrever "nome = " e
    "preco_compra = " vinte mil vezes. Em TSV o cabecalho aparece uma vez.

        //item_id  nome              preco_compra   preco_venda
        1002       "1-espada pesada" 2115           423
        1003       "2-espada e meia" 10100          1600

    Regras, escolhidas para o ficheiro continuar legivel a olho:

      - a PRIMEIRA linha comecada por // e o cabecalho, e nomeia as colunas
      - as linhas // seguintes sao comentario, tal como as que comecam por ;
      - valores separados por TAB; TABs seguidos NAO sao coluna vazia repetida,
        cada TAB separa exatamente uma coluna
      - valor entre aspas mantem os espacos e perde as aspas
      - celula vazia vira None, e nao string vazia, para casar com o que o .ini faz
        quando a chave nao aparece
      - linhas com menos celulas que o cabecalho sao completadas com None; com mais,
        as sobrantes sao ignoradas em silencio (o .scp original tem lixo no fim de linha)
    """
    registros = []
    colunas = None
    with open(caminho, encoding="utf-8") as fp:
        for linha in fp:
            linha = linha.rstrip(chr(10)).rstrip(chr(13))
            if not linha.strip():
                continue
            if linha.startswith(";"):
                continue
            if linha.startswith("//"):
                if colunas is None:
                    colunas = [c.strip() for c in _separar(linha[2:]) if c.strip()]
                continue
            if colunas is None:
                continue          # dados antes do cabecalho: nao da para nomear nada
            celulas = _separar(linha)
            reg = {}
            for i, col in enumerate(colunas):
                v = celulas[i].strip() if i < len(celulas) else ""
                if len(v) >= 2 and v[0] == chr(34) and v[-1] == chr(34):
                    reg[col] = _desescapar(v[1:-1])
                elif v == "":
                    # Celula vazia vira None. No .ini havia DUAS formas de dizer o mesmo --
                    # "chave = " vazia, e a chave nem aparecer -- e em colunas isso colapsa
                    # numa so. Para quem le com .get() e indistinguivel, e passa a haver a
                    # garantia util de que todas as linhas tem as mesmas chaves.
                    reg[col] = None
                else:
                    reg[col] = _converter(_desescapar(v))
            registros.append(reg)
    return registros


def _ler_arquivo(caminho):
    """Le um .ini nosso. Leitor proprio, nao o configparser.

    O configparser leva 6,4 s no Item.ini (19806 registros) por causa de interpolacao,
    valores multi-linha e normalizacao que nao usamos -- medido em 24/08. Como o
    exportador escapa toda quebra de linha, cada valor cabe em "chave = valor" numa linha,
    e um laco simples resolve em fracao disso.

    Dois estilos, detectados pelo conteudo:
      secoes  -- [chave] seguido de coluna = valor. Usado no conteudo em massa.
      plano   -- chave = valor e chave.coluna = valor. Usado em Common/, no estilo dos
                 .ini de servidor, com a descricao virando bloco de comentario ;-- acima.
    """
    registros, atual = [], None
    col_secao = None
    col_chave = col_valor = None
    nota_acumulada = []
    por_chave = {}
    ultimo = None            # (dicionario, chave) do ultimo valor lido
    with open(caminho, encoding="utf-8") as fp:
        for linha in fp:
            linha = linha.rstrip(chr(10)).rstrip(chr(13))
            if not linha:
                continue
            if linha[0] == ";":
                corpo = linha[1:].strip()
                if corpo.startswith("--"):
                    nota_acumulada.append(corpo[2:].strip())
                if corpo.startswith("colunas:"):
                    # A primeira coluna e a que vira nome de secao; sem isto o [08 03]
                    # do Opcode.ini era descartado e a tabela vinha sem a coluna op.
                    col_secao = corpo[8:].split(",")[0].strip()
                    continue
                # A dica de colunas e uma linha ";  chave = valor" com UMA palavra de cada
                # lado. Aceitar qualquer comentario com "=" pegava frase solta: no Level.ini
                # a linha "; Formato: nivel = exp, uma linha por nivel" virava nome de coluna
                # e a tabela inteira sumia.
                elif col_chave is None and re.fullmatch(r"\w+ = \w+", corpo):
                    a, b = corpo.split("=", 1)
                    col_chave, col_valor = a.strip(), b.strip()
                continue
            if linha[0] == "[":
                atual = {}
                if col_secao:
                    atual[col_secao] = _converter(linha[1:linha.rindex("]")])
                registros.append(atual)
                nota_acumulada = []
                ultimo = None
                continue
            # LINHA DE CONTINUACAO: indentada, logo depois de um valor. Serve pra nota
            # longa caber no arquivo sem virar uma linha unica quilometrica.
            #
            # Ate 25/08 o leitor descartava essas linhas caladas -- o "if = not in linha:
            # continue" logo abaixo comia todas. O Opcode.ini tinha 36 linhas assim, e a
            # documentacao que eu vinha escrevendo entrava so pela primeira linha: o
            # arquivo dizia uma coisa e a tabela carregada dizia outra.
            #
            # So vale se o valor anterior for TEXTO. Emendar continuacao num numero seria
            # transformar 8648 em "8648 sei la o que", calado, e ai o estrago sai do
            # comentario e entra no jogo.
            if linha[0] in (" ", chr(9)) and ultimo is not None:
                dono, chave_ant = ultimo
                if isinstance(dono.get(chave_ant), str):
                    dono[chave_ant] += NL + linha.strip()
                    continue
            if "=" not in linha:
                continue
            nome, valor = linha.split("=", 1)
            # So a CHAVE leva strip. No valor, tiro exatamente o unico espaco que o
            # exportador poe depois do "=" -- nada alem disso. Dar strip() no valor
            # apagava espaco de verdade: 11 itens do catalogo tem espaco no fim ou um
            # nao-separavel ( ) na frente, e o strip do Python leva os dois embora.
            nome = nome.strip()
            # Comentario no fim da linha, pra tabela ficar legivel: exige DOIS espacos
            # antes do ponto-e-virgula. Um espaco so nao serve -- 28 descricoes de item
            # tem ponto-e-virgula no meio da frase ("pelas sombras; a gravacao..."), e
            # cortar ali comeria metade do texto. Com dois espacos, nenhuma delas casa.
            corte = re.search(r"  +;", valor)
            if corte:
                valor = valor[:corte.start()]
            if valor[:1] == " ":
                valor = valor[1:]
            if atual is not None:
                atual[nome] = _converter(valor)
                ultimo = (atual, nome)
                continue
            # estilo plano: "alz" ou "alz.implementado"
            if "." in nome:
                chave, coluna = nome.split(".", 1)
            else:
                chave, coluna = nome, col_valor
            r = por_chave.get(chave)
            if r is None:
                r = {col_chave: _converter(chave)}
                por_chave[chave] = r
                registros.append(r)
                if nota_acumulada:
                    r["__nota__"] = " ".join(nota_acumulada)
                    nota_acumulada = []
            r[coluna] = _converter(valor)
            ultimo = (r, coluna)
    # TABELA NUMERADA, o formato do [ReqEXP]: uma secao so, e dentro dela as linhas sao
    # "numero = valor". Sem este tratamento a secao inteira virava UM registro com 200
    # chaves, e quem pedia indice("Level","nivel") recebia nada -- foi o que quebrou o
    # Level.ini em 25/08 ao adotar o formato de uma linha por nivel.
    if len(registros) == 1 and col_chave and col_valor:
        unico = registros[0]
        numericas = [k for k in unico if isinstance(k, str) and k.strip().isdigit()]
        if len(numericas) >= 3 and len(numericas) >= len(unico) - 2:
            registros = [{col_chave: int(k), col_valor: unico[k]} for k in numericas]

    # uniformiza: todo registro com as mesmas chaves, faltando vira None
    todas = []
    for r in registros:
        for k in r:
            if k not in todas:
                todas.append(k)
    for r in registros:
        for k in todas:
            r.setdefault(k, None)
    return registros


def carregar(verbose=False):
    """Mapeia Data/ inteira, recursivamente. NAO le o conteudo ainda.

    Cada arquivo e lido na primeira vez que alguem pede. Ler tudo na partida custava 2,4 s,
    e 80 por cento disso era o ItemOption.scp -- 34 mil linhas que uma sessao pode nunca
    tocar. Assim a partida fica instantanea e cada tabela paga o proprio custo, uma vez.
    """
    with _trava:
        _tabelas.clear()
        _indices.clear()
        _caminhos.clear()
        _mtime_common.clear()
        # VARREDURA RECURSIVA (25/08). Antes olhava so Data/ e Data/Common/, um nivel cada.
        #
        # A tabela e indexada pelo NOME DO ARQUIVO, nao pelo caminho -- tabela("Item") acha o
        # Item.ini onde quer que ele esteja. Por isso a recursao e o que permite organizar a
        # pasta em subpastas por sistema sem tocar em nenhum chamador.
        #
        # O reverso disso e o perigo: dois arquivos de mesmo nome em pastas diferentes
        # disputam a mesma chave e um sumiria calado, que e o modo de falhar que mais custou
        # tempo neste projeto. Por isso a colisao ESTOURA aqui em vez de escolher um.
        colisoes = []
        for base, pastas, arqs in os.walk(DATA):
            pastas.sort()
            for nome in sorted(arqs):
                if not (nome.endswith(".ini") or nome.endswith(".tsv")):
                    continue
                caminho = os.path.join(base, nome)
                chave = nome[:-4]
                if chave in _caminhos:
                    colisoes.append((chave, _caminhos[chave], caminho))
                    continue
                _caminhos[chave] = caminho
                # Common/ e a pasta de recarga a quente, esteja onde estiver na arvore
                if os.path.normcase(base) == os.path.normcase(COMMON):
                    _mtime_common[chave] = (caminho, os.path.getmtime(caminho))
        if colisoes:
            raise RuntimeError(
                "nomes de arquivo repetidos em Data/ -- cada tabela precisa de nome unico:"
                + "".join(chr(10) + "  %s: %s  x  %s" % c for c in colisoes))
        if verbose:
            print("Data/: %d arquivos mapeados (leitura sob demanda)" % len(_caminhos))
        return len(_caminhos)


def _ler_qualquer(caminho):
    """Escolhe o leitor pelo CONTEUDO, nao pela extensao.

    Tudo continua a chamar-se .ini. Um ficheiro esta no formato de colunas se tiver uma linha
    comecada por // antes de qualquer [seccao] -- e essa linha nomeia as colunas.

    Detetar pelo conteudo e nao por uma extensao nova poupa a toda a gente ter de saber qual
    tabela esta em que formato: converte-se uma tabela quando fizer sentido, e nenhum chamador
    muda. tabela("Item") continua a ser tabela("Item").
    """
    with open(caminho, encoding="utf-8") as fp:
        for linha in fp:
            t = linha.lstrip()
            if not t or t[0] == ";":
                continue
            if t.startswith("//"):
                return _ler_tsv(caminho)
            if t[0] == "[" or "=" in t:
                break
    return _ler_arquivo(caminho)


def _garantir(nome):
    """Le o arquivo se ainda nao foi lido.

    Chama carregar() sozinho se ninguem chamou antes. Sem isto, quem importasse o modulo e
    fosse direto em tabela()/indice() recebia lista vazia e caia calado no valor padrao --
    foi o que aconteceu em 24/08 ao converter o db.py: exp_para_subir devolvia None pra
    todo nivel e o unico teste que passou (taxa) passou por coincidencia, porque o padrao
    dele tambem era 1.0.
    """
    if not _caminhos:
        carregar()
    if nome not in _tabelas:
        caminho = _caminhos.get(nome)
        if caminho is None:
            # AVISA, nao devolve vazio calado. Pedir tabela que nao existe sempre foi um erro
            # de digitacao ou um arquivo renomeado, nunca uma situacao legitima -- e o silencio
            # aqui escondeu por dias o taxa()/bonus_exp() apontando pros nomes velhos.
            if nome not in _faltando:
                _faltando.add(nome)
                print("[data_files] AVISO: pediram a tabela %r e nao existe arquivo %s.ini "
                      "em Data/ -- devolvendo vazio" % (nome, nome))
            _tabelas[nome] = []
        else:
            _tabelas[nome] = _ler_qualquer(caminho)
    return _tabelas[nome]


def _recarregar_common(chave):
    """Common/ muda a quente. Confere o mtime e rele so o que mudou."""
    par = _mtime_common.get(chave)
    if not par:
        return
    caminho, visto = par
    try:
        agora = os.path.getmtime(caminho)
    except OSError:
        return
    if agora != visto:
        _tabelas[chave] = _ler_qualquer(caminho)
        _mtime_common[chave] = (caminho, agora)
        for k in [k for k in _indices if k[0] == chave]:
            del _indices[k]


def tabela(nome):
    """Lista de dicionarios de um arquivo. `tabela("Missao")`."""
    if nome in _mtime_common:
        _recarregar_common(nome)
    return _garantir(nome)


def indice(nome, coluna):
    """Dicionario coluna -> registro, montado uma vez e guardado."""
    if nome in _mtime_common:
        _recarregar_common(nome)
    chave = (nome, coluna)
    if chave not in _indices:
        _indices[chave] = {r[coluna]: r for r in _garantir(nome) if r.get(coluna) is not None}
    return _indices[chave]


def agrupar(nome, coluna):
    """Dicionario coluna -> lista de registros, pra quem tem varias linhas por chave."""
    chave = (nome, coluna, "grupo")
    if chave not in _indices:
        d = {}
        for r in _garantir(nome):
            d.setdefault(r.get(coluna), []).append(r)
        _indices[chave] = d
    return _indices[chave]


def taxa(codigo, padrao=1.0):
    """Multiplicador de evento de Data/Common/Rate.ini. Recarrega a quente.

    CORRIGIDO 25/08: procurava "Taxa". O arquivo virou Rate.ini na passagem dos nomes pro
    ingles e ninguem trocou aqui, entao o indice vinha vazio e esta funcao devolvia o padrao
    pra TODO codigo -- nenhum multiplicador valia nada.

    Passou despercebido porque os oito multiplicadores estao em 1.000 hoje, que e o mesmo
    valor do padrao. O erro so apareceria no dia de ligar um evento 3x, e apareceria como
    "o evento nao funciona", longe daqui.
    """
    r = indice("Rate", "codigo").get(codigo)
    if not r:
        return padrao
    v = r.get("multiplicador")
    return float(v) if v is not None else padrao


# Expressoes permitidas em Formula.ini. Nao ha eval() aqui de proposito: o arquivo e dado
# de configuracao, e dado de configuracao nao deve virar codigo executavel -- quem editar a
# Data nao deveria conseguir rodar Python dentro do servidor. O avaliador entende so numero,
# nome de variavel, os quatro operadores, potencia e parenteses.
import ast as _ast
import operator as _op

_OPS = {_ast.Add: _op.add, _ast.Sub: _op.sub, _ast.Mult: _op.mul,
        _ast.Div: _op.truediv, _ast.Pow: _op.pow, _ast.USub: _op.neg}


def _avaliar(no, vars_):
    if isinstance(no, _ast.Expression):
        return _avaliar(no.body, vars_)
    if isinstance(no, _ast.Constant) and isinstance(no.value, (int, float)):
        return no.value
    if isinstance(no, _ast.Name):
        if no.id not in vars_:
            raise ValueError("variavel desconhecida na formula: %r" % no.id)
        return vars_[no.id]
    if isinstance(no, _ast.BinOp) and type(no.op) in _OPS:
        return _OPS[type(no.op)](_avaliar(no.left, vars_), _avaliar(no.right, vars_))
    if isinstance(no, _ast.UnaryOp) and type(no.op) in _OPS:
        return _OPS[type(no.op)](_avaliar(no.operand, vars_))
    raise ValueError("expressao nao permitida em formula: %s" % type(no).__name__)


def formula(codigo, **vars_):
    """Calcula uma expressao de Data/Formula.ini. `formula("exp_para_subir", nivel=9)`

    Ate 25/08 o Formula.ini era so documentacao: a expressao de exp_para_subir estava
    escrita la E escrita de novo, em Python, no entity_sim. Editar o arquivo nao mudava
    nada no jogo -- e a mesma duplicacao de fonte que ja tinha aparecido duas vezes neste
    projeto, sobrevivendo num terceiro lugar.

    A ideia veio da pasta Data do Mu: la as curvas de EXP sao texto no FormulaData.xml e o
    Common.ini diz qual usar. A formula e dado, nao codigo.

    Devolve None se o codigo nao existir, pra quem chama decidir o que fazer.
    """
    r = indice("Formula", "codigo").get(codigo)
    if not r:
        return None
    expr = r.get("expressao")
    if not expr:
        return None
    return _avaliar(_ast.parse(str(expr), mode="eval"), vars_)


def bonus_exp():
    """Soma dos percentuais de EXP ligados em Common/ExpModifier.ini.

    CORRIGIDO 25/08: procurava "ExpModificador", e o arquivo e ExpModifier.ini. Mesmo caso
    do taxa() logo acima, e igualmente mascarado -- os quatro modificadores estao desligados,
    entao zero e a resposta certa por acidente.
    """
    return sum(int(r.get("percentual") or 0)
               for r in tabela("ExpModifier") if r.get("ativo"))


def existe(nome):
    """O arquivo existe? Diferente de estar vazio.

    A distincao importa no NpcSpawn: arquivo ausente quer dizer "nao configurado, usa o
    bando padrao", e arquivo com zero linhas quer dizer "mundo sem mob, de proposito".
    Tratar os dois como lista vazia fazia as 12 aranhas de fallback voltarem toda vez que
    a lista era esvaziada pra medir no oficial.
    """
    if not _caminhos:
        carregar()
    return nome in _caminhos


def caminho(nome, subpasta=None):
    """Onde esta o arquivo de uma tabela. `caminho("Level")` -> .../Data/Character/Level.ini

    Existe por causa da reorganizacao de 25/08, que moveu os arquivos pra subpastas por
    sistema. O servidor nao se importa -- ele pede por nome e o mapa resolve. Mas as
    ferramentas de tools/ montavam o caminho na mao, com "server", "Data", "Level.ini"
    escrito no meio do arquivo, e essas apontariam pro lugar errado depois da mudanca. Pior:
    apontariam pra um caminho VALIDO e inexistente, entao gravariam um arquivo novo na raiz
    da Data em vez de dar erro, e o servidor continuaria lendo o antigo. Duas fontes de
    verdade de novo, e desta vez sem aviso nenhum.

    Passando `subpasta`, devolve onde o arquivo DEVE ficar caso ainda nao exista -- e o que
    um importador precisa pra criar tabela nova no lugar certo.
    """
    if not _caminhos:
        carregar()
    achado = _caminhos.get(nome)
    if achado:
        return achado
    if subpasta:
        return os.path.join(DATA, subpasta, nome + ".ini")
    return os.path.join(DATA, nome + ".ini")


def agrupar_composto(nome, colunas):
    """Dicionario (col1, col2, ...) -> LISTA de registros.

    O `agrupar` so aceita uma coluna e o `indice_composto` guarda um registro por chave.
    O ItemBonus precisa dos dois ao mesmo tempo: a chave e (item_id, refino) e um item pode
    ter ate cinco linhas com a mesma chave, uma por caracteristica especial.
    """
    chave = (nome, tuple(colunas), "grupo_composto")
    if chave not in _indices:
        d = {}
        for r in _garantir(nome):
            k = tuple(r.get(c) for c in colunas)
            if None not in k:
                d.setdefault(k, []).append(r)
        _indices[chave] = d
    return _indices[chave]


def indice_composto(nome, colunas):
    """Dicionario (col1, col2, ...) -> registro. Para tabelas sem chave de uma coluna so."""
    chave = (nome, tuple(colunas), "composto")
    if chave not in _indices:
        d = {}
        for r in _garantir(nome):
            k = tuple(r.get(c) for c in colunas)
            if None not in k:
                d[k] = r
        _indices[chave] = d
    return _indices[chave]
