import json

from time import sleep


class GraphQL():
    """
    Cliente GraphQL generico para as integracoes do gn_integracao.

    NAO herda de gn_api — é composicao, igual ao Request. A ideia é
    envolver um Request ja configurado (base_url + headers de auth) e
    tratar as convencoes do GraphQL que o restante do framework nao
    conhece, porque foi desenhado pra REST:

      1. Erro chega em HTTP 200, dentro de 'errors[]'
      2. Mutations tem um segundo canal de erro: 'userErrors[]'
      3. Throttling é por custo de query, tambem dentro de um 200
      4. Paginacao é por cursor no corpo (pageInfo/endCursor)
      5. 'edges[].node' precisa ser achatado pro map.json conseguir navegar

    Uso tipico, dentro do __init__ da integracao:

        self.shopify_request = Request(base_url=..., headers=...)

        self.shopify_graphql = GraphQL(
            request = self.shopify_request,
            rote    = 'graphql.json',
            logger  = self
        )

    E depois, em qualquer metodo:

        data = self.shopify_graphql.run(QUERY, {'id': gid})
    """

    # Codigos de erro que valem retentativa (o resto é erro de query,
    # retentar so gastaria chamada)
    CODIGOS_RETRY = ('THROTTLED', 'INTERNAL_SERVER_ERROR', 'SERVICE_UNAVAILABLE')

    def __init__(self, request, rote='graphql.json', logger=None,
                 max_retries=5, max_pages=200, debug=False):
        """
        request     -> instancia de generic.gn_request.Request ja configurada
        rote        -> endpoint GraphQL relativo a base_url do request
        logger      -> instancia da integracao (usa o createLog dela); pode ser None
        max_retries -> tentativas por chamada em caso de throttle/erro temporario
        max_pages   -> trava de seguranca do paginate(), evita loop infinito
        debug       -> imprime o custo de cada query no stdout
        """
        self.request     = request
        self.rote        = rote
        self.logger      = logger
        self.max_retries = max_retries
        self.max_pages   = max_pages
        self.debug       = debug

        # Estado da ultima chamada — util pra quem quer inspecionar sem
        # depender do retorno
        self.last_response    = None
        self.last_errors      = []
        self.last_user_errors = []
        self.last_cost        = {}

    # ================================================================ #
    # ----------------------- LOG ------------------------------------ #
    # ================================================================ #

    def _log(self, status_code, mensagem):
        """
        Delega pro createLog da integracao. Se nao veio logger, cai no print
        pra nao engolir erro silenciosamente durante desenvolvimento.
        """
        if self.logger is None:
            print(f'[gn_graphql] {status_code}: {mensagem}')
            return

        try:
            self.logger.createLog(status_code, f'[graphql] {mensagem}')
        except Exception as error:
            print(f'[gn_graphql] falha ao logar ({error}): {status_code} {mensagem}')

    # ================================================================ #
    # ----------------------- EXECUCAO ------------------------------- #
    # ================================================================ #

    def run(self, query, variables=None, flatten=True):
        """
        Executa uma query e devolve o conteudo de 'data'.

        flatten=True (padrao) converte 'edges[].node' em lista simples, pra
        que o retorno possa ser navegado pelo JsonByPath / map.json do mesmo
        jeito que uma resposta REST. Use flatten=False quando precisar do
        envelope original (é o que o paginate faz internamente, porque ele
        precisa do pageInfo).

        Retorna {} quando a chamada falhou — sempre com o motivo no log.
        """
        self.last_errors      = []
        self.last_user_errors = []
        self.last_cost        = {}

        # copia as variaveis: o paginate reaproveita e muta o proprio dict
        # entre as paginas, e o payload nao pode enxergar essa mutacao
        payload = {'query': query, 'variables': dict(variables or {})}

        for tentativa in range(1, self.max_retries + 1):

            res = self.request.doRequest(
                method = 'POST',
                rote   = self.rote,
                json   = payload
            )

            self.last_response = res

            # --- Erro de transporte (5xx, 4xx de autenticacao etc) ---
            if not res.ok:
                espera = self._espera_http(res, tentativa)

                if espera is None:
                    self._log(res.status_code, f'HTTP {res.status_code}: {res.text[:500]}')
                    return {}

                self._log(res.status_code,
                          f'HTTP {res.status_code} — tentativa {tentativa}/{self.max_retries}, '
                          f'aguardando {espera}s')
                sleep(espera)
                continue

            # --- Resposta nao-JSON (pagina de login, HTML de erro etc) ---
            try:
                body = res.json()
            except Exception:
                self._log('500', f'resposta nao-JSON: {res.text[:500]}')
                return {}

            self.last_cost = (body.get('extensions') or {}).get('cost') or {}

            if self.debug and self.last_cost:
                print(f'[gn_graphql] custo: {json.dumps(self.last_cost)}')

            erros = body.get('errors') or []
            data  = body.get('data')

            # --- Throttle / erro temporario: espera e retenta ---
            if erros and self._tem_codigo_retry(erros):
                espera = self._espera_throttle()
                self._log('429',
                          f'THROTTLED — tentativa {tentativa}/{self.max_retries}, '
                          f'aguardando {espera}s')
                sleep(espera)
                continue

            # --- Erro de query: nao adianta retentar ---
            if erros:
                self.last_errors = erros
                self._log('400', f'errors: {json.dumps(erros, ensure_ascii=False)[:1000]}')

                # GraphQL pode devolver resultado parcial: erro em um campo e
                # dados validos nos outros. Se veio algo, entregamos.
                if not data:
                    return {}

            if data is None:
                self._log('400', 'resposta sem "data" e sem "errors" — retorno inesperado')
                return {}

            return self.flatten(data) if flatten else data

        self._log('429', f'esgotadas as {self.max_retries} tentativas')
        return {}

    def mutate(self, query, variables=None, result_path=None, flatten=True):
        """
        Igual ao run(), mas valida tambem o 'userErrors[]' — o canal de erro
        de negocio das mutations, que chega em HTTP 200 e sem 'errors[]'.

        result_path -> nome do campo da mutation (ex: 'orderUpdate'). Se nao
                       informado, procura userErrors em todos os campos de
                       primeiro nivel de 'data'.

        Retorna {} se a mutation reportou userErrors.
        """
        data = self.run(query, variables, flatten=flatten)

        if not data:
            return {}

        alvos = [data.get(result_path)] if result_path else list(data.values())

        for alvo in alvos:
            if not isinstance(alvo, dict):
                continue

            # 'userErrors' é o nome padrao; a Shopify tambem usa variantes
            # tipadas ('orderUserErrors', 'customerUserErrors', ...)
            for chave, valor in alvo.items():
                if not chave.lower().endswith('usererrors') or not valor:
                    continue

                self.last_user_errors = valor
                self._log('422', f'userErrors: {json.dumps(valor, ensure_ascii=False)[:1000]}')
                return {}

        return data

    def paginate(self, query, path, variables=None, page_size=100,
                 cursor_var='cursor', size_var='limit'):
        """
        Gerador que percorre uma connection paginada por cursor e entrega
        os nodes ja achatados, um a um.

        A query precisa declarar as variaveis de cursor e de tamanho, e
        selecionar o pageInfo:

            query($cursor: String, $limit: Int!) {
              orders(first: $limit, after: $cursor) {
                pageInfo { hasNextPage endCursor }
                edges { node { id name } }
              }
            }

            for pedido in gql.paginate(QUERY, path='orders', page_size=250):
                ...

        path -> caminho da connection dentro de 'data', separado por '/'
                (ex: 'orders' ou 'customer/orders')
        """
        variaveis = dict(variables or {})
        cursor    = None
        paginas   = 0

        # So injeta as variaveis que a query realmente declara — mandar
        # variavel nao declarada é ruido desnecessario no payload
        usa_size   = f'${size_var}'   in query
        usa_cursor = f'${cursor_var}' in query

        if usa_size:
            variaveis[size_var] = page_size

        while paginas < self.max_pages:

            if usa_cursor:
                variaveis[cursor_var] = cursor

            # flatten=False: precisamos do pageInfo, que o flatten descarta
            data = self.run(query, variaveis, flatten=False)

            if not data:
                return

            conexao = self.navigate(data, path)

            if not isinstance(conexao, dict):
                self._log('400', f'caminho "{path}" nao encontrado na resposta')
                return

            for node in self._nodes(conexao):
                yield self.flatten(node)

            paginas += 1

            page_info = conexao.get('pageInfo') or {}

            if not page_info.get('hasNextPage'):
                return

            cursor = page_info.get('endCursor')

            if not cursor:
                self._log('400', f'hasNextPage=true mas sem endCursor em "{path}" — paginacao interrompida')
                return

            if not usa_cursor:
                self._log('400', f'a query nao declara ${cursor_var} — paginacao interrompida na pagina {paginas}')
                return

        self._log('999', f'limite de {self.max_pages} paginas atingido em "{path}"')

    # ================================================================ #
    # ----------------------- AUXILIARES ----------------------------- #
    # ================================================================ #

    def _tem_codigo_retry(self, erros):
        for erro in erros:
            codigo = ((erro.get('extensions') or {}).get('code') or '').upper()
            if codigo in self.CODIGOS_RETRY:
                return True
        return False

    def _espera_throttle(self, padrao=2.0):
        """
        Calcula quanto falta pro bucket de custo repor o necessario.
        A Shopify devolve, em extensions.cost:

            requestedQueryCost, throttleStatus{ currentlyAvailable, restoreRate }

        Sem esses dados, cai num backoff fixo.
        """
        custo = self.last_cost or {}
        status = custo.get('throttleStatus') or {}

        try:
            solicitado  = float(custo.get('requestedQueryCost') or 0)
            disponivel  = float(status.get('currentlyAvailable') or 0)
            reposicao   = float(status.get('restoreRate') or 0)

            if reposicao > 0 and solicitado > disponivel:
                return round(max(1.0, (solicitado - disponivel) / reposicao), 2)
        except (TypeError, ValueError):
            pass

        return padrao

    def _espera_http(self, res, tentativa):
        """
        Define a espera pra erros de transporte. Devolve None quando o erro
        nao é retentavel (4xx de autenticacao, query malformada etc).
        """
        if res.status_code == 429:
            try:
                return float(res.headers.get('Retry-After', 2))
            except (TypeError, ValueError):
                return 2.0

        if res.status_code >= 500:
            return min(2 ** tentativa, 30)

        return None

    @staticmethod
    def navigate(data, path):
        """
        Navega um dict por caminho separado por '/'. Devolve None se o
        caminho nao existir.
        """
        atual = data

        for parte in str(path).split('/'):
            if not isinstance(atual, dict):
                return None
            atual = atual.get(parte)

        return atual

    @staticmethod
    def _nodes(conexao):
        """
        Extrai os nodes de uma connection, aceitando as duas formas:
        'edges[].node' (completa) e 'nodes[]' (abreviada).
        """
        if isinstance(conexao.get('nodes'), list):
            return conexao['nodes']

        return [
            edge.get('node')
            for edge in (conexao.get('edges') or [])
            if isinstance(edge, dict) and edge.get('node') is not None
        ]

    @classmethod
    def flatten(cls, data):
        """
        Achata recursivamente as connections do GraphQL, transformando

            { "orders": { "edges": [ { "node": {...} } ] } }

        em

            { "orders": [ {...} ] }

        É o que permite o map.json continuar escrevendo
        "integration": "customer/email" em vez de
        "integration": "customer/edges/0/node/email".

        O pageInfo é descartado no processo — quem precisa dele usa
        run(flatten=False) ou o paginate().
        """
        if isinstance(data, list):
            return [cls.flatten(item) for item in data]

        if not isinstance(data, dict):
            return data

        # É uma connection? Vira lista.
        if 'edges' in data or ('nodes' in data and 'pageInfo' in data):
            return [cls.flatten(node) for node in cls._nodes(data)]

        return {chave: cls.flatten(valor) for chave, valor in data.items()}
