"""
Inventario das ultimas versoes publicadas de todos os sistemas do repositorio.

Para cada item do catalogo em lib/catalogo_versoes.py mostra:

- o nome do projeto;
- a ultima versao publicada;
- a data em que essa versao foi gerada;
- o endereco de localizacao (imagem no registro, repositorio Git ou arquivo de
  download).

A saida em `--json` traz ainda o projeto no GitLab (id, caminho e url), os links
de listagem consultados, o digest e o tamanho da imagem.

Uso:

    python scriptsPython/listar_ultimas_versoes.py <token-gitlab>
    python scriptsPython/listar_ultimas_versoes.py <token-gitlab> --json
    python scriptsPython/listar_ultimas_versoes.py <token-gitlab> --filtro enterprise
    python scriptsPython/listar_ultimas_versoes.py <token-gitlab> --storybooks

Os storybooks ficam de fora por padrao, porque sao vitrine de componentes e nao
sistema implantado no cliente. Passe --storybooks para inclui-los.

O token tambem pode vir da variavel de ambiente GITLAB_TOKEN e precisa dos
escopos read_api e read_registry.

Codigos de saida:

    0 = todos os itens do catalogo foram consultados com sucesso
    1 = token nao informado ou ao menos um item falhou na consulta
"""

import argparse
import datetime
import json
import os
import sys
import urllib.parse

import requests

from lib.catalogo_versoes import (
    API_GITLAB,
    CATALOGO,
    SEM_ENDERECO_FIXO,
    TIPO_DOCKER,
    TIPO_DOWNLOAD,
    TIPO_GIT,
    extrair_versoes_da_listagem,
    filtrar_catalogo,
    ha_storybook_correspondente,
    selecionar_ultima_versao,
)
from lib.echo import echo


TIMEOUT_SEGUNDOS = 30

# Guarda contra paginacao infinita se a API devolver header estranho
MAXIMO_DE_PAGINAS = 50


class ErroConsulta(Exception):
    """Falha ao consultar uma origem (rede, permissao, item inexistente)."""


class ClienteGitLab():
    def __init__(self, token):
        self.sessao = requests.Session()
        self.sessao.headers.update({"PRIVATE-TOKEN": token})

    def obter(self, caminho, parametros=None):
        url = f"{API_GITLAB}{caminho}"

        try:
            resposta = self.sessao.get(url, params=parametros, timeout=TIMEOUT_SEGUNDOS)
        except requests.RequestException as erro:
            raise ErroConsulta(f"falha de rede em {url}: {erro}")

        if resposta.status_code == 401 or resposta.status_code == 403:
            raise ErroConsulta(f"HTTP {resposta.status_code} em {url} (token sem permissao ou expirado)")

        if resposta.status_code == 404:
            raise ErroConsulta(f"HTTP 404 em {url} (projeto ou repositorio nao encontrado)")

        if not resposta.ok:
            raise ErroConsulta(f"HTTP {resposta.status_code} em {url}")

        return resposta

    def listar_paginado(self, caminho):
        itens = []
        pagina = 1
        total_de_paginas = 1

        while pagina <= total_de_paginas and pagina <= MAXIMO_DE_PAGINAS:
            resposta = self.obter(caminho, {"per_page": 100, "page": pagina})

            itens.extend(resposta.json())

            try:
                total_de_paginas = int(resposta.headers.get("x-total-pages") or 1)
            except ValueError:
                total_de_paginas = 1

            pagina += 1

        return itens


# ---------------------------------------------------------------------------
# Consulta por tipo
# ---------------------------------------------------------------------------

def resolver_projeto(cliente, entrada):
    """
    Dados do projeto no GitLab.

    Tenta primeiro o id que ja esta nos scripts de hoje e, se ele nao resolver,
    o caminho derivado da imagem. Assim as imagens que ainda nao tem script
    proprio (Cassini, renderizador, storybooks) tambem entram no inventario.
    """

    identificadores = []

    if entrada.projeto:
        identificadores.append(str(entrada.projeto))

    if entrada.caminho_projeto:
        identificadores.append(urllib.parse.quote(entrada.caminho_projeto, safe=""))

    if not identificadores:
        raise ErroConsulta("catalogo sem id de projeto e sem imagem para derivar o caminho")

    ultimo_erro = None

    for identificador in identificadores:
        try:
            return cliente.obter(f"/projects/{identificador}").json()
        except ErroConsulta as erro:
            ultimo_erro = erro

    raise ultimo_erro


def resolver_repositorio_do_registro(cliente, entrada, projeto):
    """
    Repositorio do registro correspondente a imagem, e a divergencia encontrada.

    O id de repositorio do registro e global na instancia do GitLab, entao o id
    escrito nos scripts pode apontar para o repositorio de outro projeto. Por
    isso a busca e pela localizacao da imagem, com o id apenas como alternativa.
    """

    repositorios = cliente.listar_paginado(f"/projects/{projeto['id']}/registry/repositories")

    localizacao_esperada = (entrada.localizacao_esperada or "").lower()

    for repositorio in repositorios:
        if str(repositorio.get("location", "")).lower() == localizacao_esperada:
            divergencia = None

            if entrada.repositorio and entrada.repositorio != repositorio["id"]:
                divergencia = (
                    f"id de repositorio configurado nos scripts ({entrada.repositorio}) "
                    f"e diferente do id real ({repositorio['id']})"
                )

            return repositorio, divergencia

    for repositorio in repositorios:
        if repositorio.get("id") == entrada.repositorio:
            divergencia = (
                f"a imagem esperada ({localizacao_esperada}) nao existe neste projeto; "
                f"usando o repositorio de id {repositorio['id']} ({repositorio.get('location')})"
            )

            return repositorio, divergencia

    raise ErroConsulta(
        f"nenhum repositorio do registro casou com {localizacao_esperada} "
        f"no projeto {projeto.get('path_with_namespace')}"
    )


def consultar_docker(cliente, entrada):
    projeto = resolver_projeto(cliente, entrada)
    repositorio, divergencia = resolver_repositorio_do_registro(cliente, entrada, projeto)

    caminho_tags = f"/projects/{projeto['id']}/registry/repositories/{repositorio['id']}/tags"

    tags = cliente.listar_paginado(caminho_tags)
    nomes = [tag.get("name") for tag in tags if tag.get("name")]

    versoes = []

    for plataforma in (entrada.plataformas or (None,)):
        nome_tag = selecionar_ultima_versao(nomes, plataforma)

        if not nome_tag:
            versoes.append({
                "plataforma": plataforma,
                "versao": None,
                "geradaEm": None,
                "erro": "nenhuma tag encontrada",
            })

            continue

        detalhe = {}
        erro_detalhe = None

        try:
            detalhe = cliente.obter(f"{caminho_tags}/{urllib.parse.quote(nome_tag, safe='')}").json()
        except ErroConsulta as erro:
            erro_detalhe = str(erro)

        versoes.append({
            "plataforma": plataforma,
            "versao": nome_tag,
            "geradaEm": detalhe.get("created_at"),
            "digest": detalhe.get("digest"),
            "tamanhoBytes": detalhe.get("total_size"),
            "imagemCompleta": detalhe.get("location") or f"{repositorio.get('location')}:{nome_tag}",
            "erro": erro_detalhe,
        })

    return {
        "chave": entrada.chave,
        "nome": entrada.nome,
        "tipo": TIPO_DOCKER,
        "projeto": dados_do_projeto(projeto),
        "localizacao": repositorio.get("location"),
        "totalDeTags": len(nomes),
        "linksListagem": [f"{API_GITLAB}{caminho_tags}"],
        "divergencia": divergencia,
        "versoes": versoes,
    }


def consultar_git(cliente, entrada):
    projeto = resolver_projeto(cliente, entrada)

    usar_tags = not entrada.somente_branches
    usar_branches = bool(entrada.incluir_branches or entrada.somente_branches)

    caminho_tags = f"/projects/{projeto['id']}/repository/tags"
    caminho_branches = f"/projects/{projeto['id']}/repository/branches"

    tags = cliente.listar_paginado(caminho_tags) if usar_tags else []
    branches = cliente.listar_paginado(caminho_branches) if usar_branches else []

    datas_por_nome = {}
    origens_por_nome = {}

    for tag in tags:
        nome = tag.get("name")

        if not nome:
            continue

        commit = tag.get("commit") or {}

        datas_por_nome[nome] = commit.get("created_at") or commit.get("committed_date")
        origens_por_nome[nome] = "tag"

    for branch in branches:
        nome = branch.get("name")

        if not nome or nome in datas_por_nome:
            continue

        commit = branch.get("commit") or {}

        datas_por_nome[nome] = commit.get("committed_date") or commit.get("created_at")
        origens_por_nome[nome] = "branch"

    ultima = selecionar_ultima_versao(list(datas_por_nome))

    links = []

    if usar_tags:
        links.append(f"{API_GITLAB}{caminho_tags}")

    if usar_branches:
        links.append(f"{API_GITLAB}{caminho_branches}")

    return {
        "chave": entrada.chave,
        "nome": entrada.nome,
        "tipo": TIPO_GIT,
        "projeto": dados_do_projeto(projeto),
        "localizacao": projeto.get("http_url_to_repo") or projeto.get("web_url"),
        "totalDeTags": len(tags),
        "totalDeBranches": len(branches),
        "linksListagem": links,
        "divergencia": None,
        "versoes": [{
            "plataforma": None,
            "versao": ultima,
            "geradaEm": datas_por_nome.get(ultima),
            "origem": origens_por_nome.get(ultima),
            "erro": None if ultima else "nenhuma tag ou branch encontrada",
        }],
    }


def consultar_download(entrada):
    try:
        resposta = requests.get(entrada.listagem, timeout=TIMEOUT_SEGUNDOS)
        resposta.raise_for_status()
    except requests.RequestException as erro:
        raise ErroConsulta(f"falha ao abrir {entrada.listagem}: {erro}")

    versoes = extrair_versoes_da_listagem(resposta.text, entrada.padrao_arquivo)

    if not versoes:
        return {
            "chave": entrada.chave,
            "nome": entrada.nome,
            "tipo": TIPO_DOWNLOAD,
            "projeto": None,
            "localizacao": entrada.listagem,
            "totalDeVersoes": 0,
            "linksListagem": [entrada.listagem],
            "divergencia": None,
            "versoes": [{
                "plataforma": None,
                "versao": None,
                "geradaEm": None,
                "erro": "nenhum arquivo encontrado na listagem",
            }],
        }

    arquivo, versao = versoes[0]
    url_arquivo = f"{entrada.listagem}{arquivo}"

    gerada_em = None

    try:
        cabecalho = requests.head(url_arquivo, timeout=TIMEOUT_SEGUNDOS)

        if cabecalho.ok:
            gerada_em = cabecalho.headers.get("last-modified")
    except requests.RequestException:
        gerada_em = None

    return {
        "chave": entrada.chave,
        "nome": entrada.nome,
        "tipo": TIPO_DOWNLOAD,
        "projeto": None,
        "localizacao": url_arquivo,
        "totalDeVersoes": len(versoes),
        "linksListagem": [entrada.listagem],
        "divergencia": None,
        "versoes": [{
            "plataforma": None,
            "versao": versao,
            "geradaEm": gerada_em,
            "erro": None,
        }],
    }


def dados_do_projeto(projeto):
    return {
        "id": projeto.get("id"),
        "nome": projeto.get("name"),
        "caminho": projeto.get("path_with_namespace"),
        "url": projeto.get("web_url"),
    }


def consultar(cliente, entrada):
    try:
        if entrada.tipo == TIPO_DOCKER:
            return consultar_docker(cliente, entrada)

        if entrada.tipo == TIPO_GIT:
            return consultar_git(cliente, entrada)

        return consultar_download(entrada)
    except ErroConsulta as erro:
        return {
            "chave": entrada.chave,
            "nome": entrada.nome,
            "tipo": entrada.tipo,
            "projeto": None,
            "localizacao": entrada.localizacao_esperada,
            "linksListagem": [link for link in [entrada.link_listagem_configurado()] if link],
            "divergencia": None,
            "erro": str(erro),
            "versoes": [],
        }


# ---------------------------------------------------------------------------
# Saida
# ---------------------------------------------------------------------------

def formatar_data(valor):
    if not valor:
        return "-"

    texto = str(valor).strip()

    try:
        data = datetime.datetime.fromisoformat(texto.replace("Z", "+00:00"))
    except ValueError:
        try:
            data = datetime.datetime.strptime(texto, "%a, %d %b %Y %H:%M:%S %Z")
        except ValueError:
            return texto

    if data.tzinfo is not None:
        data = data.astimezone()

    return data.strftime("%d/%m/%Y %H:%M")


def imprimir_texto(resultados):
    for resultado in resultados:
        echo("")
        echo(f"== {resultado['nome']}  [{resultado['chave']}]")

        if resultado.get("erro"):
            echo(f"   erro ......... {resultado['erro']}")

        for versao in resultado.get("versoes") or []:
            rotulo = "ultima versao"

            if versao.get("plataforma"):
                rotulo = f"ultima versao ({versao['plataforma']})"

            if not versao.get("versao"):
                echo(f"   {rotulo} .. {versao.get('erro') or 'nao encontrada'}")
                continue

            origem = f" [{versao['origem']}]" if versao.get("origem") else ""

            echo(f"   {rotulo} .. {versao['versao']}{origem}   gerada em {formatar_data(versao.get('geradaEm'))}")

            if versao.get("erro"):
                echo(f"   aviso ........ data nao obtida: {versao['erro']}")

        if resultado.get("localizacao"):
            echo(f"   localizacao .. {resultado['localizacao']}")

        if resultado.get("divergencia"):
            echo(f"   atencao ...... {resultado['divergencia']}")

    echo("")
    echo("== Sem endereco fixo no repositorio")

    for entrada in SEM_ENDERECO_FIXO:
        echo(f"   {entrada.nome} [{entrada.chave}]: {entrada.observacao}")

    echo("")


def montar_saida_json(resultados):
    return {
        "geradoEm": datetime.datetime.now().astimezone().isoformat(),
        "sistemas": resultados,
        "semEnderecoFixo": [
            {"chave": entrada.chave, "nome": entrada.nome, "observacao": entrada.observacao}
            for entrada in SEM_ENDERECO_FIXO
        ],
    }


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def analisar_argumentos(argumentos):
    analisador = argparse.ArgumentParser(
        description="Lista a ultima versao publicada de cada sistema do repositorio."
    )

    analisador.add_argument(
        "token",
        nargs="?",
        help="token de acesso do GitLab (escopos read_api e read_registry). Alternativa: variavel GITLAB_TOKEN."
    )

    analisador.add_argument("--json", action="store_true", help="devolve o inventario em JSON")
    analisador.add_argument("--filtro", help="consulta somente os itens cuja chave, nome ou imagem contenham o texto")
    analisador.add_argument(
        "--storybooks",
        action="store_true",
        help="inclui os storybooks no inventario (por padrao ficam de fora)"
    )

    return analisador.parse_args(argumentos)


def main(argumentos=None):
    opcoes = analisar_argumentos(sys.argv[1:] if argumentos is None else argumentos)

    token = opcoes.token or os.environ.get("GITLAB_TOKEN")

    if not token:
        echo("Token nao informado. Passe o token como argumento ou defina GITLAB_TOKEN.", "error")

        return 1

    entradas = filtrar_catalogo(opcoes.filtro, opcoes.storybooks)

    if not entradas:
        if not opcoes.storybooks and ha_storybook_correspondente(opcoes.filtro):
            echo(
                f"Nenhum item casou com o filtro '{opcoes.filtro}' fora dos storybooks. "
                "Use --storybooks para inclui-los.",
                "error"
            )
        else:
            echo(f"Nenhum item do catalogo casou com o filtro '{opcoes.filtro}'.", "error")

        return 1

    cliente = ClienteGitLab(token)

    resultados = []

    # Sequencial de proposito, para nao esbarrar no rate limit da API do GitLab.
    for entrada in entradas:
        if not opcoes.json:
            print(f"consultando {entrada.chave}...", file=sys.stderr)

        resultados.append(consultar(cliente, entrada))

    if opcoes.json:
        print(json.dumps(montar_saida_json(resultados), indent=2, ensure_ascii=False))
    else:
        imprimir_texto(resultados)

    houve_falha = any(resultado.get("erro") for resultado in resultados)

    return 1 if houve_falha else 0


if __name__ == "__main__":
    exit(main())
