"""This package facilitates HTTP/REST requests to the registry."""

import httplib
import json
import urllib

from containerregistry.client import docker_creds
from containerregistry.client import docker_name

# Options for docker_http.Transport actions
PULL = 'pull'
PUSH = 'push,pull'
# For now DELETE is PUSH, which is the read/write ACL.
DELETE = PUSH
ACTIONS = [PULL, PUSH, DELETE]


class Diagnostic(object):
  """Diagnostic encapsulates a Registry v2 diagnostic message.

  This captures one of the "errors" from a v2 Registry error response
  message, as outlined here:
    https://github.com/docker/distribution/blob/master/docs/spec/api.md#errors

  Args:
    error: dict, the decoded JSON of the "errors" array element.
  """

  def __init__(self, error):
    self._error = error

  def __eq__(self, other):
    return (self.code == other.code and
            self.message == other.message and
            self.detail == other.detail)

  @property
  def code(self):
    return self._error.get('code')

  @property
  def message(self):
    return self._error.get('message')

  @property
  def detail(self):
    return self._error.get('detail')


def _DiagnosticsFromContent(content):
  try:
    o = json.loads(content)
    return [Diagnostic(d) for d in o.get('errors', [])]
  except:
    return [Diagnostic({
        'code': 'UNKNOWN',
        'message': content,
    })]


class V2DiagnosticException(Exception):
  """Exceptions when an unexpected HTTP status is returned."""

  def __init__(self, resp, content):
    self._resp = resp
    self._diagnostics = _DiagnosticsFromContent(content)
    message = '\n'.join(['response: %s' % resp] + [
        '%s: %s' % (d.message, d.detail) for d in self._diagnostics])
    super(V2DiagnosticException, self).__init__(message)

  @property
  def diagnostics(self):
    return self._diagnostics


class BadStateException(Exception):
  """Exceptions when we have entered an unexpected state."""


def _CheckState(predicate, message=None):
  if not predicate:
    raise BadStateException(message if message else 'Unknown')


USER_AGENT = '//cloud/containers/build:docker_pusher'

_CHALLENGE = 'Bearer '
_REALM_PFX = 'realm='
_SERVICE_PFX = 'service='


class Transport(object):
  """HTTP Transport abstraction to handle automatic v2 reauthentication.

  In the v2 Registry protocol, all of the API endpoints expect to receive
  'Bearer' authentication.  These Bearer tokens are generated by exchanging
  'Basic' authentication with an authentication endpoint designated by the
  opening ping request.

  The Bearer tokens are scoped to a resource (typically repository), and
  are generated with a set of capabilities embedded (e.g. push, pull).

  The Docker client has a baked in 60-second expiration for Bearer tokens,
  and upon expiration, registries can reject any request with a 401.  The
  transport should automatically refresh the Bearer token and reissue the
  request.

  Args:
     name: docker_name.Repository, the structured name of the docker image
           being referenced.
     creds: docker_creds.Basic, the basic authentication credentials to use for
            authentication challenge exchanges.
     transport: httplib2.Transport, the HTTP transport to use under the hood.
     action: One of docker_http.ACTIONS, for which we plan to use this transport
  """

  def __init__(self, name, creds, transport, action):
    if not isinstance(name, docker_name.Repository):
      raise ValueError('Expected docker_name.Repository for "name"')
    self._name = name
    self._basic_creds = creds
    self._transport = transport
    self._action = action

    _CheckState(action in ACTIONS,
                'Invalid action supplied to docker_http.Transport: %s' % action)

    # Ping once to establish realm, and then get a good credential
    # for use with this transport.
    self._Ping()
    self._Refresh()

  def _Ping(self):
    """Ping the v2 Registry.

    Only called during transport construction, this pings the listed
    v2 registry.  The point of this ping is to establish the "realm"
    and "service" to use for Basic for Bearer-Token exchanges.
    """
    # This initiates the pull by issuing a v2 ping:
    #   GET H:P/v2/
    headers = {
        'content-type': 'application/json',
        'User-Agent': USER_AGENT,
    }
    resp, unused_content = self._transport.request(
        'https://{registry}/v2/'.format(
            registry=self._name.registry),
        'GET', body=None, headers=headers)

    # We expect a www-authenticate challenge.
    _CheckState(resp.status == httplib.UNAUTHORIZED,
                'Unexpected status: %d' % resp.status)

    challenge = resp['www-authenticate']
    _CheckState(challenge.startswith(_CHALLENGE),
                'Unexpected "www-authenticate" header: %s' % challenge)

    # Default "_service" to the registry
    self._service = self._name.registry

    tokens = challenge[len(_CHALLENGE):].split(',')
    for t in tokens:
      if t.startswith(_REALM_PFX):
        self._realm = t[len(_REALM_PFX):].strip('"')
      elif t.startswith(_SERVICE_PFX):
        self._service = t[len(_SERVICE_PFX):].strip('"')

    # Make sure these got set.
    _CheckState(self._realm, 'Expected a "%s" in "www-authenticate" '
                'header: %s' % (_REALM_PFX, challenge))

  def _Scope(self):
    """Construct the resource scope to pass to a v2 auth endpoint."""
    return 'repository:{repository}:{action}'.format(
        repository=self._name.repository,
        action=self._action)

  def _Refresh(self):
    """Refreshes the Bearer token credentials underlying this transport.

    This utilizes the "realm" and "service" established during _Ping to
    set up _bearer_creds with up-to-date credentials, by passing the
    client-provided _basic_creds to the authorization realm.

    This is generally called under two circumstances:
      1) When the transport is created (eagerly)
      2) When a request fails on a 401 Unauthorized
    """
    headers = {
        'content-type': 'application/json',
        'User-Agent': USER_AGENT,
        'Authorization': self._basic_creds.Get()
    }
    parameters = {
        'scope': self._Scope(),
        'service': self._service,
    }
    resp, content = self._transport.request(
        # 'realm' includes scheme and path
        '{realm}?{query}'.format(
            realm=self._realm,
            query=urllib.urlencode(parameters)),
        'GET', body=None, headers=headers)

    _CheckState(resp.status == httplib.OK,
                'Bad status during token exchange: %d' % resp.status)

    wrapper_object = json.loads(content)
    _CheckState('token' in wrapper_object,
                'Malformed JSON response: %s' % content)

    # We have successfully reauthenticated.
    self._bearer_creds = docker_creds.Bearer(wrapper_object['token'])

  # pylint: disable=invalid-name
  def Request(self, url, accepted_codes=None, method=None,
              body=None, content_type=None):
    """Wrapper containing much of the boilerplate REST logic for Registry calls.

    Args:
      url: str, the URL to which to talk
      accepted_codes: the list of acceptable http status codes
      method: str, the HTTP method to use (defaults to GET/PUT depending on
              whether body is provided)
      body: str, the body to pass into the PUT request (or None for GET)
      content_type: str, the mime-type of the request (or None for JSON)

    Raises:
      BadStateException: an unexpected internal state has been encountered.
      V2DiagnosticException: an error has occured interacting with v2.

    Returns:
      The response of the HTTP request, and its contents.
    """
    if not method:
      method = 'GET' if not body else 'PUT'

    # If the first request fails on a 401 Unauthorized, then refresh the
    # Bearer token and retry.
    for retry in [True, False]:
      # self._bearer_creds may be changed by self._Refresh(), so do
      # not hoist this.
      headers = {
          'content-type': content_type if content_type else 'application/json',
          'Authorization': self._bearer_creds.Get(),
          'User-Agent': USER_AGENT,
      }

      # POST/PUT require a content-length, when no body is supplied.
      if method in ('POST', 'PUT') and not body:
        headers['content-length'] = '0'

      resp, content = self._transport.request(
          url, method, body=body, headers=headers)

      if resp.status != httplib.UNAUTHORIZED:
        break
      elif retry:
        # On Unauthorized, refresh the credential and retry.
        self._Refresh()

    if resp.status not in accepted_codes:
      # Use the content returned by GCR as the error message.
      raise V2DiagnosticException(resp, content)

    return resp, content
