"""
Consulta ao registro de imagens Docker usando a Docker Registry HTTP API V2.

E usado para duas coisas:

- listar as tags de um repositorio, para resolver `master` na versao oficial
  mais recente;
- verificar se uma tag existe, para avisar antes de tentar atualizar com uma
  imagem inexistente.

Implementado somente com a biblioteca padrao do Python (urllib), para nao
adicionar dependencia nova aos scripts de implantacao.

Fluxo de autenticacao (padrao do registro do GitLab):

1. GET https://<registro>/v2/  -> responde 401 com o header
   WWW-Authenticate: Bearer realm="https://gitlab.../jwt/auth",service="container_registry"
2. GET <realm>?service=<service>&scope=repository:<repositorio>:pull
   com Basic auth -> responde {"token": "..."}
3. GET https://<registro>/v2/<repositorio>/tags/list com Bearer <token>
"""

import base64
import json
import re
import ssl
import urllib.error
import urllib.parse
import urllib.request

from lib.echo import echo


REGISTRO_PADRAO = "gitlab.microuniverso.com.br:5050"

TIMEOUT_SEGUNDOS = 20

PYTHON_DA_IMPLANTACAO = r"c:\microuniversoWeb\python\python.exe"


def _bundle_certificados_certifi():
    """
    Caminho do bundle de raizes do certifi, ou None se ele nao estiver instalado.

    Serve de alternativa quando o armazenamento de certificados do Windows do servidor nao tem a
    raiz que assinou o certificado do registro. Isso acontece em servidor antigo (Server 2012 R2 e
    parecidos) ou com a atualizacao automatica de raizes desligada por politica: o navegador
    funciona, porque o Windows baixa a raiz sob demanda, e o Python falha com
    "unable to get local issuer certificate". O certifi ja vem instalado como dependencia do
    requests, entao isso nao adiciona dependencia nova.
    """

    try:
        import certifi
    except ImportError:
        return None

    return certifi.where()


def _eh_falha_de_certificado(erro):
    motivo = getattr(erro, "reason", erro)

    return isinstance(motivo, ssl.SSLCertVerificationError)

TIPOS_MANIFESTO_ACEITOS = ", ".join([
    "application/vnd.docker.distribution.manifest.v2+json",
    "application/vnd.docker.distribution.manifest.list.v2+json",
    "application/vnd.oci.image.manifest.v1+json",
    "application/vnd.oci.image.index.v1+json",
    "application/vnd.docker.distribution.manifest.v1+json",
])


class ErroRegistroDocker(Exception):
    """Falha ao conversar com o registro (rede, credencial, indisponibilidade)."""

    def __init__(self, mensagem, eh_erro_autenticacao=False):
        super().__init__(mensagem)
        self.mensagem = mensagem
        self.eh_erro_autenticacao = eh_erro_autenticacao

    def __str__(self):
        return self.mensagem


def separar_referencia_imagem(referencia):
    """
    Separa "registro/repositorio:tag" em (registro, repositorio, tag).

    O registro so e reconhecido quando o primeiro segmento tem "." ou ":",
    que e como o Docker diferencia host de namespace.
    """

    if not referencia:
        return "", "", ""

    texto = str(referencia).strip()

    tag = ""
    ultima_barra = texto.rfind("/")
    ultimo_doispontos = texto.rfind(":")

    if ultimo_doispontos > ultima_barra:
        tag = texto[ultimo_doispontos + 1:]
        texto = texto[:ultimo_doispontos]

    partes = texto.split("/", 1)

    if len(partes) == 2 and ("." in partes[0] or ":" in partes[0]):
        return partes[0], partes[1], tag

    return "", texto, tag


def montar_referencia_imagem(imagem, tag):
    return f"{imagem}:{tag}" if tag else str(imagem)


def _decodificar_json(corpo):
    return json.loads(corpo.decode("utf-8"))


def _extrair_desafio_autenticacao(cabecalho):
    """Le realm e service do header WWW-Authenticate."""

    if not cabecalho:
        return None, None

    realm = None
    service = None

    correspondencia_realm = re.search(r'realm="([^"]+)"', cabecalho)
    correspondencia_service = re.search(r'service="([^"]+)"', cabecalho)

    if correspondencia_realm:
        realm = correspondencia_realm.group(1)

    if correspondencia_service:
        service = correspondencia_service.group(1)

    return realm, service


class RegistroDocker():
    """Cliente de leitura do registro de imagens Docker."""

    def __init__(self, registro=REGISTRO_PADRAO, usuario=None, senha=None, timeout=TIMEOUT_SEGUNDOS):
        self.registro = registro or REGISTRO_PADRAO
        self.usuario = usuario
        self.senha = senha
        self.timeout = timeout

        self._realm = None
        self._service = None
        self._desafio_consultado = False
        self._tokens = {}
        self._cache_tags = {}
        self._cache_manifesto = {}
        self._contexto_ssl = None
        self._usando_bundle_certifi = False

    # -----------------------------------------------------------------------
    # Infra HTTP
    # -----------------------------------------------------------------------

    def _url_base(self):
        return f"https://{self.registro}"

    def _cabecalho_basic(self):
        if not self.usuario or not self.senha:
            return None

        credencial = f"{self.usuario}:{self.senha}".encode("utf-8")

        return "Basic " + base64.b64encode(credencial).decode("ascii")

    def _obter_contexto_ssl(self):
        """
        Contexto TLS da conexao. Comeca no padrao, que no Windows usa o armazenamento de
        certificados da maquina e resolve tambem o caso de raiz privada instalada pelo cliente.
        """

        if self._contexto_ssl is None:
            self._contexto_ssl = ssl.create_default_context()

        return self._contexto_ssl

    def _trocar_para_bundle_certifi(self):
        """Troca para as raizes do certifi. Retorna False se nao houver essa alternativa."""

        if self._usando_bundle_certifi:
            return False

        bundle = _bundle_certificados_certifi()

        if not bundle:
            return False

        self._contexto_ssl = ssl.create_default_context(cafile=bundle)
        self._usando_bundle_certifi = True

        echo(
            f"[WARN] O certificado de {self.registro} nao foi validado pelo armazenamento de "
            f"certificados desta maquina. Repetindo com as raizes do certifi ({bundle}).",
            "warn"
        )

        return True

    def _mensagem_falha_de_certificado(self, erro):
        return (
            f"Falha ao acessar o registro {self.registro}: o certificado do servidor nao pudo ser "
            f"validado ({getattr(erro, 'reason', erro)}). Falta a raiz certificadora nesta maquina. "
            f"O certificado do registro e emitido pela Let's Encrypt e a raiz da cadeia e a "
            f"ISRG Root X1; Windows Server 2012 R2 sem a atualizacao automatica de raizes nao a tem. "
            f"Opcoes, em ordem de preferencia: 1) instalar a ISRG Root X1 "
            f"(https://letsencrypt.org/certs/isrgrootx1.pem) em Autoridades de Certificacao Raiz "
            f"Confiaveis do Windows, que conserta tambem docker login, git e navegador na maquina; "
            f"2) atualizar o bundle de raizes do Python com "
            f"'{PYTHON_DA_IMPLANTACAO} -m pip install --upgrade certifi'; "
            f"3) '{PYTHON_DA_IMPLANTACAO} -m pip install pip-system-certs', que faz o Python usar o "
            f"verificador de certificados do proprio Windows (ele baixa a raiz que falta sob demanda), "
            f"com a ressalva de que isso altera o comportamento de TLS de todo script Python da "
            f"implantacao; 4) apontar um bundle de raizes na variavel de ambiente SSL_CERT_FILE; "
            f"5) informar uma versao especifica no lugar de master, que dispensa a consulta ao registro."
        )

    def _requisitar(self, url, cabecalhos=None, metodo="GET"):
        """Executa a requisicao. Retorna (status, cabecalhos, corpo)."""

        requisicao = urllib.request.Request(url, method=metodo)

        for nome, valor in (cabecalhos or {}).items():
            requisicao.add_header(nome, valor)

        try:
            with urllib.request.urlopen(
                requisicao,
                timeout=self.timeout,
                context=self._obter_contexto_ssl()
            ) as resposta:
                return resposta.status, dict(resposta.headers), resposta.read()
        except urllib.error.HTTPError as erro:
            corpo = b""

            try:
                corpo = erro.read()
            except Exception:
                pass

            return erro.code, dict(erro.headers or {}), corpo
        except Exception as erro:
            if _eh_falha_de_certificado(erro):
                if self._trocar_para_bundle_certifi():
                    return self._requisitar(url, cabecalhos, metodo)

                raise ErroRegistroDocker(self._mensagem_falha_de_certificado(erro))

            raise ErroRegistroDocker(
                f"Falha ao acessar o registro {self.registro}: {erro}"
            )

    def _descobrir_desafio_autenticacao(self):
        if self._desafio_consultado:
            return

        self._desafio_consultado = True

        status, cabecalhos, _ = self._requisitar(f"{self._url_base()}/v2/")

        if status == 401:
            self._realm, self._service = _extrair_desafio_autenticacao(
                cabecalhos.get("WWW-Authenticate") or cabecalhos.get("Www-Authenticate")
            )

    def _obter_token(self, repositorio):
        if repositorio in self._tokens:
            return self._tokens[repositorio]

        self._descobrir_desafio_autenticacao()

        if not self._realm:
            # Registro sem autenticacao por token; segue sem Bearer.
            self._tokens[repositorio] = None
            return None

        parametros = {"scope": f"repository:{repositorio}:pull"}

        if self._service:
            parametros["service"] = self._service

        url = f"{self._realm}?{urllib.parse.urlencode(parametros)}"

        cabecalhos = {}
        cabecalho_basic = self._cabecalho_basic()

        if cabecalho_basic:
            cabecalhos["Authorization"] = cabecalho_basic

        status, _, corpo = self._requisitar(url, cabecalhos)

        if status in (401, 403):
            raise ErroRegistroDocker(
                f"Credenciais recusadas pelo registro {self.registro} ao consultar "
                f"o repositorio {repositorio}. Verifique acessos.registroDocker.usuario e senha.",
                eh_erro_autenticacao=True
            )

        if status != 200:
            raise ErroRegistroDocker(
                f"Falha ao autenticar no registro {self.registro} "
                f"(HTTP {status}) para o repositorio {repositorio}."
            )

        try:
            dados = _decodificar_json(corpo)
        except Exception as erro:
            raise ErroRegistroDocker(
                f"Resposta de autenticacao invalida do registro {self.registro}: {erro}"
            )

        token = dados["token"] if "token" in dados else dados.get("access_token")

        self._tokens[repositorio] = token

        return token

    def _cabecalhos_autenticados(self, repositorio):
        cabecalhos = {}

        token = self._obter_token(repositorio)

        if token:
            cabecalhos["Authorization"] = f"Bearer {token}"
        else:
            cabecalho_basic = self._cabecalho_basic()

            if cabecalho_basic:
                cabecalhos["Authorization"] = cabecalho_basic

        return cabecalhos

    # -----------------------------------------------------------------------
    # API publica
    # -----------------------------------------------------------------------

    def listar_tags(self, repositorio):
        """Retorna a lista de tags do repositorio."""

        if repositorio in self._cache_tags:
            return self._cache_tags[repositorio]

        cabecalhos = self._cabecalhos_autenticados(repositorio)

        tags = []
        caminho = f"/v2/{repositorio}/tags/list?n=100"

        while caminho:
            status, cabecalhos_resposta, corpo = self._requisitar(
                f"{self._url_base()}{caminho}",
                cabecalhos
            )

            if status == 404:
                raise ErroRegistroDocker(
                    f"Repositorio {repositorio} nao encontrado no registro {self.registro}."
                )

            if status in (401, 403):
                raise ErroRegistroDocker(
                    f"Sem permissao de leitura no repositorio {repositorio} "
                    f"do registro {self.registro}.",
                    eh_erro_autenticacao=True
                )

            if status != 200:
                raise ErroRegistroDocker(
                    f"Falha ao listar tags de {repositorio} no registro "
                    f"{self.registro} (HTTP {status})."
                )

            try:
                dados = _decodificar_json(corpo)
            except Exception as erro:
                raise ErroRegistroDocker(
                    f"Resposta invalida ao listar tags de {repositorio}: {erro}"
                )

            tags_pagina = dados["tags"] if "tags" in dados and dados["tags"] else []

            tags.extend([tag for tag in tags_pagina if tag])

            caminho = self._proxima_pagina(cabecalhos_resposta)

        self._cache_tags[repositorio] = tags

        return tags

    def _proxima_pagina(self, cabecalhos_resposta):
        link = cabecalhos_resposta.get("Link") or cabecalhos_resposta.get("link")

        if not link:
            return None

        correspondencia = re.search(r'<([^>]+)>\s*;\s*rel="next"', link)

        return correspondencia.group(1) if correspondencia else None

    def tag_existe(self, repositorio, tag):
        """Indica se a tag existe no repositorio, consultando o manifesto."""

        chave = (repositorio, tag)

        if chave in self._cache_manifesto:
            return self._cache_manifesto[chave]

        cabecalhos = self._cabecalhos_autenticados(repositorio)
        cabecalhos["Accept"] = TIPOS_MANIFESTO_ACEITOS

        status, _, _ = self._requisitar(
            f"{self._url_base()}/v2/{repositorio}/manifests/{urllib.parse.quote(str(tag))}",
            cabecalhos
        )

        if status == 200:
            self._cache_manifesto[chave] = True
            return True

        if status == 404:
            self._cache_manifesto[chave] = False
            return False

        if status in (401, 403):
            raise ErroRegistroDocker(
                f"Sem permissao para consultar {repositorio}:{tag} no registro "
                f"{self.registro}.",
                eh_erro_autenticacao=True
            )

        raise ErroRegistroDocker(
            f"Falha ao consultar {repositorio}:{tag} no registro "
            f"{self.registro} (HTTP {status})."
        )


def obter_registro_docker(referencia_imagem, usuario, senha):
    """Cria o cliente do registro a partir da referencia completa da imagem."""

    registro, repositorio, _ = separar_referencia_imagem(referencia_imagem)

    return RegistroDocker(
        registro=registro or REGISTRO_PADRAO,
        usuario=usuario,
        senha=senha
    ), (repositorio or referencia_imagem)


def echoar_falha_registro(erro, contexto):
    echo(f"[WARN] Nao foi possivel consultar o registro Docker para {contexto}: {erro}", "warn")
