"""
Preparacao da versao de um sistema Docker antes de subir o container.

Reune as tres validacoes que precisam acontecer antes de qualquer
`docker compose up`:

1. Resolucao de `master`: quando a versao configurada e `master` (ou `latest`),
   a versao oficial mais recente publicada no registro e usada no lugar.

2. Validacao da imagem: a tag informada precisa existir no registro. Se nao
   existir, a implantacao para com mensagem clara em vez de falhar no meio do
   `docker compose pull`.

3. Mapa de dependencias: a combinacao de versoes dos sistemas precisa ser
   valida segundo `mapa-dependencias.yaml`.

Uso tipico em um configurar_*.py:

    from lib.versoes_sistemas import ErroVersaoSistema, preparar_versao_sistema

    try:
        versao = preparar_versao_sistema(
            configuracoes,
            "centralDeRelatorios",
            diretorio_scripts_implantacao
        )
    except ErroVersaoSistema as erro:
        echo(str(erro), "error")
        exit(1)

As consultas ao registro sao cacheadas por processo, entao chamar a funcao para
varios sistemas nao multiplica requisicoes.
"""

from lib.echo import echo
from lib.mapa_dependencias import (
    ErroMapaDependencias,
    carregar_mapa_dependencias,
    validar_dependencias,
)
from lib.registro_docker import (
    ErroRegistroDocker,
    RegistroDocker,
    separar_referencia_imagem,
)
from lib.sistemas_docker import (
    SISTEMAS_DOCKER,
    eh_valor_placeholder,
    obter_sistema_docker,
)
from lib.versoes_docker import (
    REFERENCIAS_VERSAO_MAIS_RECENTE,
    eh_referencia_versao_mais_recente,
    listar_versoes_oficiais,
    ordenar_tags_da_mais_recente_para_mais_antiga,
    selecionar_versao_oficial_mais_recente,
)


class ErroVersaoSistema(Exception):
    """
    Versao configurada invalida, inexistente ou incompativel.

    `caminho_configuracao` guarda o campo do YAML que precisa ser corrigido
    (ex.: `enterprise.imagem.versao`). Sistemas diferentes podem compartilhar o
    mesmo campo, como a Enterprise e o renderizador; nesse caso o campo permite
    reportar o problema uma unica vez em vez de repetir o erro por sistema.
    """

    def __init__(self, mensagem, caminho_configuracao=None):
        super().__init__(mensagem)
        self.mensagem = mensagem
        self.caminho_configuracao = caminho_configuracao

    def __str__(self):
        return self.mensagem


_cache_registros = {}
_cache_versoes_resolvidas = {}
_cache_mapa_dependencias = {}


def limpar_caches():
    """Usado pelos testes e por execucoes longas que recarregam configuracao."""

    _cache_registros.clear()
    _cache_versoes_resolvidas.clear()
    _cache_mapa_dependencias.clear()


class RegistroComFallbackDeCredencial():
    """
    Registro que tenta mais de um par de credenciais.

    Alguns sistemas tem token proprio no YAML (`enterprise.imagem.senha`, por
    exemplo) que costuma estar desatualizado nas configuracoes existentes.
    Quando o token especifico e recusado, este wrapper repete a consulta com as
    credenciais gerais de `acessos.registroDocker` e passa a usar a que
    funcionou.
    """

    def __init__(self, registro_host, credenciais):
        self._candidatos = [
            RegistroDocker(registro=registro_host, usuario=usuario, senha=senha)
            for usuario, senha in credenciais
        ]

        if not self._candidatos:
            self._candidatos = [RegistroDocker(registro=registro_host)]

        self._indice_em_uso = 0

    def _executar(self, nome_operacao, *argumentos):
        ultimo_erro = None

        for indice in range(self._indice_em_uso, len(self._candidatos)):
            operacao = getattr(self._candidatos[indice], nome_operacao)

            try:
                resultado = operacao(*argumentos)
                self._indice_em_uso = indice
                return resultado
            except ErroRegistroDocker as erro:
                ultimo_erro = erro

                if not erro.eh_erro_autenticacao:
                    raise

        raise ultimo_erro

    def listar_tags(self, repositorio):
        return self._executar("listar_tags", repositorio)

    def tag_existe(self, repositorio, tag):
        return self._executar("tag_existe", repositorio, tag)


def _obter_registro(sistema, configuracoes):
    imagem = sistema.obter_imagem(configuracoes)

    if not imagem:
        raise ErroVersaoSistema(
            f"Nome da imagem Docker do sistema {sistema.nome} nao esta configurado."
        )

    registro_host, repositorio, _ = separar_referencia_imagem(imagem)
    credenciais = sistema.obter_credenciais_registro(configuracoes)

    chave_cache = (registro_host, tuple(usuario for usuario, _ in credenciais))

    if chave_cache not in _cache_registros:
        _cache_registros[chave_cache] = RegistroComFallbackDeCredencial(
            registro_host,
            credenciais
        )

    return _cache_registros[chave_cache], repositorio, imagem


def _resolver_versao_mais_recente(sistema, configuracoes, registro, repositorio):
    plataforma = sistema.obter_plataforma(configuracoes)

    try:
        tags = registro.listar_tags(repositorio)
    except ErroRegistroDocker as erro:
        raise ErroVersaoSistema(
            "\n".join([
                f"{sistema.nome}: nao foi possivel descobrir a versao oficial mais recente "
                "porque o registro Docker nao respondeu.",
                f"  Corrija em: {sistema.caminho_versao_texto()}",
                f"  Detalhe: {erro}",
                "  Informe uma versao especifica ou corrija o acesso ao registro.",
            ]),
            caminho_configuracao=sistema.caminho_versao_texto()
        )

    versao = selecionar_versao_oficial_mais_recente(tags, plataforma=plataforma)

    if not versao:
        complemento_plataforma = f" para a plataforma {plataforma}" if plataforma else ""

        # Aqui nao existe versao oficial para sugerir, entao as tags cruas ajudam
        # a entender o que o repositorio tem. Ficam rotuladas como nao oficiais.
        tags_existentes = ordenar_tags_da_mais_recente_para_mais_antiga(tags)[:10]

        raise ErroVersaoSistema(
            "\n".join([
                f"{sistema.nome}: a versao esta configurada como "
                f"'{sistema.obter_versao(configuracoes)}', mas nao existe nenhuma versao "
                f"oficial publicada{complemento_plataforma} neste repositorio.",
                f"  Corrija em: {sistema.caminho_versao_texto()}",
                f"  Imagem: {repositorio}",
                f"  Tags existentes, nenhuma oficial: {', '.join(tags_existentes) or 'nenhuma'}",
                "  Publique uma versao oficial ou informe uma versao especifica.",
            ]),
            caminho_configuracao=sistema.caminho_versao_texto()
        )

    echo(
        f"{sistema.nome}: versao configurada como 'master'. "
        f"Usando a versao oficial mais recente do registro: {versao}"
    )

    return versao


def _validar_imagem_existe(sistema, configuracoes, registro, repositorio, versao):
    try:
        existe = registro.tag_existe(repositorio, versao)
    except ErroRegistroDocker as erro:
        # Registro indisponivel nao deve impedir a implantacao de uma versao que
        # possivelmente ja esta baixada no servidor. Apenas avisa.
        echo(
            f"[WARN] Nao foi possivel validar no registro se a imagem "
            f"{repositorio}:{versao} do sistema {sistema.nome} existe: {erro} "
            "A implantacao vai continuar e o erro aparecera no docker compose, se houver.",
            "warn"
        )
        return

    if existe:
        echo(f"{sistema.nome}: imagem {repositorio}:{versao} encontrada no registro.")
        return

    raise ErroVersaoSistema(
        _montar_mensagem_versao_inexistente(sistema, configuracoes, registro, repositorio, versao),
        caminho_configuracao=sistema.caminho_versao_texto()
    )


def _montar_mensagem_versao_inexistente(sistema, configuracoes, registro, repositorio, versao):
    """
    Mensagem de versao inexistente, em linhas curtas.

    A mensagem diz, nesta ordem: qual sistema falhou, qual versao nao existe,
    onde corrigir e quais versoes servem. As sugestoes trazem somente versoes
    oficiais, nunca tags de branch ou de teste.
    """

    plataforma = sistema.obter_plataforma(configuracoes)

    try:
        tags = registro.listar_tags(repositorio)
    except ErroRegistroDocker:
        tags = []

    versoes_oficiais = listar_versoes_oficiais(tags, plataforma=plataforma, limite=8)

    linhas = [
        f"{sistema.nome}: a versao \"{versao}\" nao existe no registro Docker.",
        f"  Corrija em: {sistema.caminho_versao_texto()}",
        f"  Imagem: {repositorio}",
    ]

    if versoes_oficiais:
        linhas.append(f"  Versoes oficiais disponiveis: {', '.join(versoes_oficiais)}")
        linhas.append(
            f"  Ou use '{REFERENCIAS_VERSAO_MAIS_RECENTE[0]}' para sempre implantar a versao oficial mais recente."
        )
    else:
        complemento_plataforma = f" para a plataforma {plataforma}" if plataforma else ""
        linhas.append(
            f"  Nenhuma versao oficial{complemento_plataforma} foi encontrada neste repositorio. "
            "Confira o nome da imagem e se a versao ja foi publicada."
        )

    return "\n".join(linhas)


def resolver_versao_sistema(configuracoes, chave_sistema, validar_imagem=True):
    """
    Resolve e valida a versao de um sistema.

    Devolve a tag concreta que deve ser usada no compose.
    """

    sistema = obter_sistema_docker(chave_sistema)

    if chave_sistema in _cache_versoes_resolvidas:
        return _cache_versoes_resolvidas[chave_sistema]

    versao_configurada = sistema.obter_versao(configuracoes)

    if not versao_configurada:
        raise ErroVersaoSistema(
            "\n".join([
                f"{sistema.nome}: a versao nao esta informada na configuracao.",
                f"  Corrija em: {sistema.caminho_versao_texto()}",
                f"  Informe uma versao ou '{REFERENCIAS_VERSAO_MAIS_RECENTE[0]}' "
                "para usar a versao oficial mais recente.",
            ]),
            caminho_configuracao=sistema.caminho_versao_texto()
        )

    if eh_valor_placeholder(versao_configurada):
        raise ErroVersaoSistema(
            "\n".join([
                f"{sistema.nome}: a versao ainda esta com o valor de modelo "
                f"\"{versao_configurada}\".",
                f"  Corrija em: {sistema.caminho_versao_texto()}",
                f"  Informe uma versao real ou '{REFERENCIAS_VERSAO_MAIS_RECENTE[0]}' "
                "para usar a versao oficial mais recente.",
            ]),
            caminho_configuracao=sistema.caminho_versao_texto()
        )

    registro, repositorio, _ = _obter_registro(sistema, configuracoes)

    if eh_referencia_versao_mais_recente(versao_configurada):
        versao = _resolver_versao_mais_recente(sistema, configuracoes, registro, repositorio)
    else:
        versao = versao_configurada

        if validar_imagem:
            _validar_imagem_existe(sistema, configuracoes, registro, repositorio, versao)

    _cache_versoes_resolvidas[chave_sistema] = versao

    return versao


def validar_imagem_sistema(configuracoes, chave_sistema, versao):
    """
    Valida que a imagem de um sistema existe no registro numa versao ja
    determinada em outro lugar.

    Usado quando dois sistemas compartilham o mesmo campo de versao no YAML e
    precisam subir com a mesma tag. E o caso do renderizador da Enterprise, que
    usa `enterprise.imagem.versao`: resolver `master` separadamente para cada um
    poderia escolher tags diferentes se os repositorios divergirem.
    """

    sistema = obter_sistema_docker(chave_sistema)

    registro, repositorio, _ = _obter_registro(sistema, configuracoes)

    _validar_imagem_existe(sistema, configuracoes, registro, repositorio, versao)


def _obter_regras(diretorio_scripts_implantacao):
    if diretorio_scripts_implantacao in _cache_mapa_dependencias:
        return _cache_mapa_dependencias[diretorio_scripts_implantacao]

    regras = carregar_mapa_dependencias(diretorio_scripts_implantacao)

    _cache_mapa_dependencias[diretorio_scripts_implantacao] = regras

    return regras


def _resolver_versoes_dos_sistemas_das_regras(configuracoes, regras, chave_sistema=None):
    """
    Resolve as versoes apenas dos sistemas citados nas regras relevantes.

    Sistemas nao configurados ficam fora do resultado, o que faz a regra ser
    ignorada. Falha ao resolver a versao de um sistema vizinho tambem deixa o
    sistema fora do resultado, com aviso, para nao derrubar a implantacao do
    sistema atual por causa de um problema no vizinho.
    """

    chaves_necessarias = set()

    for regra in regras:
        if chave_sistema and chave_sistema not in regra.sistemas():
            continue

        chaves_necessarias.update(regra.sistemas())

    versoes = {}

    for chave in sorted(chaves_necessarias):
        sistema = obter_sistema_docker(chave)

        if not sistema.esta_configurado(configuracoes):
            continue

        try:
            versoes[chave] = resolver_versao_sistema(
                configuracoes,
                chave,
                validar_imagem=False
            )
        except ErroVersaoSistema as erro:
            if chave == chave_sistema:
                raise

            echo(
                f"[WARN] Nao foi possivel determinar a versao de {sistema.nome} para avaliar o "
                f"mapa de dependencias: {erro}",
                "warn"
            )

    return versoes


def validar_dependencias_sistema(configuracoes, chave_sistema, diretorio_scripts_implantacao):
    """Avalia apenas as regras do mapa que envolvem o sistema informado."""

    try:
        regras = _obter_regras(diretorio_scripts_implantacao)
    except ErroMapaDependencias as erro:
        raise ErroVersaoSistema(str(erro))

    if not regras:
        return

    versoes = _resolver_versoes_dos_sistemas_das_regras(
        configuracoes,
        regras,
        chave_sistema=chave_sistema
    )

    violacoes = validar_dependencias(versoes, regras, chave_sistema=chave_sistema)

    if violacoes:
        raise ErroVersaoSistema(
            "\n".join([violacao.mensagem() for violacao in violacoes])
        )


def preparar_versao_sistema(configuracoes, chave_sistema, diretorio_scripts_implantacao):
    """
    Resolve `master`, valida a existencia da imagem e checa o mapa de
    dependencias. Devolve a tag que deve ser usada no compose.

    Levanta `ErroVersaoSistema` quando a implantacao nao deve prosseguir.
    """

    versao = resolver_versao_sistema(configuracoes, chave_sistema)

    validar_dependencias_sistema(configuracoes, chave_sistema, diretorio_scripts_implantacao)

    return versao


def validar_todos_os_sistemas(configuracoes, diretorio_scripts_implantacao):
    """
    Validacao completa, usada pelo script de pre-implantacao.

    Resolve a versao de todos os sistemas configurados, valida as imagens e
    avalia o mapa de dependencias inteiro de uma vez.

    Retorna (versoes_por_sistema, lista_de_mensagens_de_erro).
    """

    try:
        regras = _obter_regras(diretorio_scripts_implantacao)
    except ErroMapaDependencias as erro:
        return {}, [str(erro)]

    versoes = {}
    erros = []
    campos_com_erro = set()

    for sistema in SISTEMAS_DOCKER:
        if not sistema.esta_configurado(configuracoes):
            continue

        # Sistemas que leem o mesmo campo de versao (Enterprise e renderizador,
        # por exemplo) tem a mesma causa raiz. Reportar uma vez por campo evita
        # repetir o erro e deixar o log confuso.
        if sistema.caminho_versao_texto() in campos_com_erro:
            continue

        try:
            versoes[sistema.chave] = resolver_versao_sistema(configuracoes, sistema.chave)
        except ErroVersaoSistema as erro:
            erros.append(str(erro))

            if erro.caminho_configuracao:
                campos_com_erro.add(erro.caminho_configuracao)

    violacoes = validar_dependencias(versoes, regras)

    erros.extend([violacao.mensagem() for violacao in violacoes])

    return versoes, erros
