import logging
import os.path
import base64
import re

from calendar import timegm
from functools import wraps
from ssl import SSLError

from github import (Github, UnknownObjectException, GithubException,
                    BadCredentialsException as GitHubBadCredentialsException)

from jsonschema import validate

from app import app, github_trigger
from buildtrigger.triggerutil import (RepositoryReadException, TriggerActivationException,
                                      TriggerDeactivationException, TriggerStartException,
                                      EmptyRepositoryException, ValidationRequestException,
                                      SkipRequestException, InvalidPayloadException,
                                      determine_build_ref, raise_if_skipped_build,
                                      find_matching_branches)
from buildtrigger.basehandler import BuildTriggerHandler
from endpoints.exception import ExternalServiceError
from util.security.ssh import generate_ssh_keypair
from util.dict_wrappers import JSONPathDict, SafeDictSetter

logger = logging.getLogger(__name__)

GITHUB_WEBHOOK_PAYLOAD_SCHEMA = {
  'type': 'object',
  'properties': {
    'ref': {
      'type': 'string',
    },
    'head_commit': {
      'type': ['object', 'null'],
      'properties': {
        'id': {
          'type': 'string',
        },
        'url': {
          'type': 'string',
        },
        'message': {
          'type': 'string',
        },
        'timestamp': {
          'type': 'string',
        },
        'author': {
          'type': 'object',
          'properties': {
            'username': {
              'type': 'string'
            },
            'html_url': {
              'type': 'string'
            },
            'avatar_url': {
              'type': 'string'
            },
          },
        },
        'committer': {
          'type': 'object',
          'properties': {
            'username': {
              'type': 'string'
            },
            'html_url': {
              'type': 'string'
            },
            'avatar_url': {
              'type': 'string'
            },
          },
        },
      },
      'required': ['id', 'url', 'message', 'timestamp'],
    },
    'repository': {
      'type': 'object',
      'properties': {
        'ssh_url': {
          'type': 'string',
        },
      },
      'required': ['ssh_url'],
    },
  },
  'required': ['ref', 'head_commit', 'repository'],
}

def get_transformed_webhook_payload(gh_payload, default_branch=None, lookup_user=None):
  """ Returns the GitHub webhook JSON payload transformed into our own payload
      format. If the gh_payload is not valid, returns None.
  """
  try:
    validate(gh_payload, GITHUB_WEBHOOK_PAYLOAD_SCHEMA)
  except Exception as exc:
    raise InvalidPayloadException(exc.message)

  payload = JSONPathDict(gh_payload)

  if payload['head_commit'] is None:
    raise SkipRequestException

  config = SafeDictSetter()
  config['commit'] = payload['head_commit.id']
  config['ref'] = payload['ref']
  config['default_branch'] = default_branch
  config['git_url'] = payload['repository.ssh_url']

  config['commit_info.url'] = payload['head_commit.url']
  config['commit_info.message'] = payload['head_commit.message']
  config['commit_info.date'] = payload['head_commit.timestamp']

  config['commit_info.author.username'] = payload['head_commit.author.username']
  config['commit_info.author.url'] = payload.get('head_commit.author.html_url')
  config['commit_info.author.avatar_url'] = payload.get('head_commit.author.avatar_url')

  config['commit_info.committer.username'] = payload.get('head_commit.committer.username')
  config['commit_info.committer.url'] = payload.get('head_commit.committer.html_url')
  config['commit_info.committer.avatar_url'] = payload.get('head_commit.committer.avatar_url')

  # Note: GitHub doesn't always return the extra information for users, so we do the lookup
  # manually if possible.
  if (lookup_user and not payload.get('head_commit.author.html_url') and
      payload.get('head_commit.author.username')):
    author_info = lookup_user(payload['head_commit.author.username'])
    if author_info:
      config['commit_info.author.url'] = author_info['html_url']
      config['commit_info.author.avatar_url'] = author_info['avatar_url']

  if (lookup_user and
      payload.get('head_commit.committer.username') and
      not payload.get('head_commit.committer.html_url')):
    committer_info = lookup_user(payload['head_commit.committer.username'])
    if committer_info:
      config['commit_info.committer.url'] = committer_info['html_url']
      config['commit_info.committer.avatar_url'] = committer_info['avatar_url']

  return config.dict_value()


def _catch_ssl_errors(func):
  @wraps(func)
  def wrapper(*args, **kwargs):
    try:
      return func(*args, **kwargs)
    except SSLError as se:
      msg = 'Request to the GitHub API failed: %s' % se.message
      logger.exception(msg)
      raise ExternalServiceError(msg)
  return wrapper


class GithubBuildTrigger(BuildTriggerHandler):
  """
  BuildTrigger for GitHub that uses the archive API and buildpacks.
  """
  def _get_client(self):
    """ Returns an authenticated client for talking to the GitHub API. """
    return Github(self.auth_token,
                  base_url=github_trigger.api_endpoint(),
                  client_id=github_trigger.client_id(),
                  client_secret=github_trigger.client_secret(),
                  timeout=5)

  @classmethod
  def service_name(cls):
    return 'github'

  def is_active(self):
    return 'hook_id' in self.config

  def get_repository_url(self):
    source = self.config['build_source']
    return github_trigger.get_public_url(source)

  @staticmethod
  def _get_error_message(ghe, default_msg):
    if ghe.data.get('errors') and ghe.data['errors'][0].get('message'):
      return ghe.data['errors'][0]['message']

    return default_msg

  @_catch_ssl_errors
  def activate(self, standard_webhook_url):
    config = self.config
    new_build_source = config['build_source']
    gh_client = self._get_client()

    # Find the GitHub repository.
    try:
      gh_repo = gh_client.get_repo(new_build_source)
    except UnknownObjectException:
      msg = 'Unable to find GitHub repository for source: %s' % new_build_source
      raise TriggerActivationException(msg)

    # Add a deploy key to the GitHub repository.
    public_key, private_key = generate_ssh_keypair()
    config['credentials'] = [
      {
        'name': 'SSH Public Key',
        'value': public_key,
      },
    ]

    try:
      deploy_key = gh_repo.create_key('%s Builder' % app.config['REGISTRY_TITLE'],
                                      public_key)
      config['deploy_key_id'] = deploy_key.id
    except GithubException as ghe:
      default_msg = 'Unable to add deploy key to repository: %s' % new_build_source
      msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
      raise TriggerActivationException(msg)

    # Add the webhook to the GitHub repository.
    webhook_config = {
      'url': standard_webhook_url,
      'content_type': 'json',
    }

    try:
      hook = gh_repo.create_hook('web', webhook_config)
      config['hook_id'] = hook.id
      config['master_branch'] = gh_repo.default_branch
    except GithubException as ghe:
      default_msg = 'Unable to create webhook on repository: %s' % new_build_source
      msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
      raise TriggerActivationException(msg)

    return config, {'private_key': private_key}

  @_catch_ssl_errors
  def deactivate(self):
    config = self.config
    gh_client = self._get_client()

    # Find the GitHub repository.
    try:
      repo = gh_client.get_repo(config['build_source'])
    except UnknownObjectException:
      msg = 'Unable to find GitHub repository for source: %s' % config['build_source']
      raise TriggerDeactivationException(msg)
    except GitHubBadCredentialsException:
      msg = 'Unable to access repository to disable trigger'
      raise TriggerDeactivationException(msg)

    # If the trigger uses a deploy key, remove it.
    try:
      if config['deploy_key_id']:
        deploy_key = repo.get_key(config['deploy_key_id'])
        deploy_key.delete()
    except KeyError:
      # There was no config['deploy_key_id'], thus this is an old trigger without a deploy key.
      pass
    except GithubException as ghe:
      default_msg = 'Unable to remove deploy key: %s' % config['deploy_key_id']
      msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
      raise TriggerDeactivationException(msg)

    # Remove the webhook.
    if 'hook_id' in config:
      try:
        hook = repo.get_hook(config['hook_id'])
        hook.delete()
      except GithubException as ghe:
        default_msg = 'Unable to remove hook: %s' % config['hook_id']
        msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
        raise TriggerDeactivationException(msg)

    config.pop('hook_id', None)
    self.config = config
    return config

  @_catch_ssl_errors
  def list_build_source_namespaces(self):
    gh_client = self._get_client()
    usr = gh_client.get_user()

    # Build the full set of namespaces for the user, starting with their own.
    namespaces = {}
    namespaces[usr.login] = {
      'personal': True,
      'id': usr.login,
      'title': usr.name or usr.login,
      'avatar_url': usr.avatar_url,
      'url': usr.html_url,
      'score': usr.plan.private_repos if usr.plan else 0,
    }

    for org in usr.get_orgs():
      organization = org.login if org.login else org.name
      namespaces[organization] = {
        'personal': False,
        'id': organization,
        'title': organization,
        'avatar_url': org.avatar_url,
        'url': org.html_url,
        'score': org.plan.private_repos if org.plan else 0,
      }

    return BuildTriggerHandler.build_namespaces_response(namespaces)

  @_catch_ssl_errors
  def list_build_sources_for_namespace(self, namespace):
    def repo_view(repo):
      return {
        'name': repo.name,
        'full_name': repo.full_name,
        'description': repo.description or '',
        'last_updated': timegm(repo.pushed_at.utctimetuple()) if repo.pushed_at else 0,
        'url': repo.html_url,
        'has_admin_permissions': repo.permissions.admin,
        'private': repo.private,
      }

    gh_client = self._get_client()
    usr = gh_client.get_user()
    if namespace == usr.login:
      repos = [repo_view(repo) for repo in usr.get_repos() if repo.owner.login == namespace]
      return BuildTriggerHandler.build_sources_response(repos)

    try:
      org = gh_client.get_organization(namespace)
      if org is None:
        return []
    except GithubException:
      return []

    repos = [repo_view(repo) for repo in org.get_repos(type='member')]
    return BuildTriggerHandler.build_sources_response(repos)


  @_catch_ssl_errors
  def list_build_subdirs(self):
    config = self.config
    gh_client = self._get_client()
    source = config['build_source']

    try:
      repo = gh_client.get_repo(source)

      # Find the first matching branch.
      repo_branches = self.list_field_values('branch_name') or []
      branches = find_matching_branches(config, repo_branches)
      branches = branches or [repo.default_branch or 'master']
      default_commit = repo.get_branch(branches[0]).commit
      commit_tree = repo.get_git_tree(default_commit.sha, recursive=True)

      return [elem.path for elem in commit_tree.tree
              if (elem.type == u'blob' and self.filename_is_dockerfile(os.path.basename(elem.path)))]
    except GithubException as ghe:
      message = ghe.data.get('message', 'Unable to list contents of repository: %s' % source)
      if message == 'Branch not found':
        raise EmptyRepositoryException()

      raise RepositoryReadException(message)

  @_catch_ssl_errors
  def load_dockerfile_contents(self):
    config = self.config
    gh_client = self._get_client()
    source = config['build_source']

    try:
      repo = gh_client.get_repo(source)
    except GithubException as ghe:
      message = ghe.data.get('message', 'Unable to list contents of repository: %s' % source)
      raise RepositoryReadException(message)

    path = self.get_dockerfile_path()
    if not path:
      return None

    try:
      file_info = repo.get_file_contents(path)
    # TypeError is needed because directory inputs cause a TypeError
    except (GithubException, TypeError) as ghe:
      logger.error("got error from trying to find github file %s" % ghe)
      return None

    if file_info is None:
      return None

    if isinstance(file_info, list):
      return None

    content = file_info.content
    if file_info.encoding == 'base64':
      content = base64.b64decode(content)
    return content

  @_catch_ssl_errors
  def list_field_values(self, field_name, limit=None):
    if field_name == 'refs':
      branches = self.list_field_values('branch_name')
      tags = self.list_field_values('tag_name')

      return ([{'kind': 'branch', 'name': b} for b in branches] +
              [{'kind': 'tag', 'name': tag} for tag in tags])

    config = self.config
    source = config.get('build_source')
    if source is None:
      return []

    if field_name == 'tag_name':
      try:
        gh_client = self._get_client()
        repo = gh_client.get_repo(source)
        gh_tags = repo.get_tags()
        if limit:
          gh_tags = repo.get_tags()[0:limit]

        return [tag.name for tag in gh_tags]
      except GitHubBadCredentialsException:
        return []
      except GithubException:
        logger.exception("Got GitHub Exception when trying to list tags for trigger %s",
                         self.trigger.id)
        return []

    if field_name == 'branch_name':
      try:
        gh_client = self._get_client()
        repo = gh_client.get_repo(source)
        gh_branches = repo.get_branches()
        if limit:
          gh_branches = repo.get_branches()[0:limit]

        branches = [branch.name for branch in gh_branches]

        if not repo.default_branch in branches:
          branches.insert(0, repo.default_branch)

        if branches[0] != repo.default_branch:
          branches.remove(repo.default_branch)
          branches.insert(0, repo.default_branch)

        return branches
      except GitHubBadCredentialsException:
        return ['master']
      except GithubException:
        logger.exception("Got GitHub Exception when trying to list branches for trigger %s",
                         self.trigger.id)
        return ['master']

    return None

  @classmethod
  def _build_metadata_for_commit(cls, commit_sha, ref, repo):
    try:
      commit = repo.get_commit(commit_sha)
    except GithubException:
      logger.exception('Could not load commit information from GitHub')
      return None

    commit_info = {
      'url': commit.html_url,
      'message': commit.commit.message,
      'date': commit.last_modified
    }

    if commit.author:
      commit_info['author'] = {
        'username': commit.author.login,
        'avatar_url': commit.author.avatar_url,
        'url': commit.author.html_url
      }

    if commit.committer:
      commit_info['committer'] = {
        'username': commit.committer.login,
        'avatar_url': commit.committer.avatar_url,
        'url': commit.committer.html_url
      }

    return {
      'commit': commit_sha,
      'ref': ref,
      'default_branch': repo.default_branch,
      'git_url': repo.ssh_url,
      'commit_info': commit_info
    }

  @_catch_ssl_errors
  def manual_start(self, run_parameters=None):
    config = self.config
    source = config['build_source']

    try:
      gh_client = self._get_client()
      repo = gh_client.get_repo(source)
      default_branch = repo.default_branch
    except GithubException as ghe:
      msg = GithubBuildTrigger._get_error_message(ghe, 'Unable to start build trigger')
      raise TriggerStartException(msg)

    def get_branch_sha(branch_name):
      try:
        branch = repo.get_branch(branch_name)
        return branch.commit.sha
      except GithubException:
        raise TriggerStartException('Could not find branch in repository')

    def get_tag_sha(tag_name):
      tags = {tag.name: tag for tag in repo.get_tags()}
      if not tag_name in tags:
        raise TriggerStartException('Could not find tag in repository')

      return tags[tag_name].commit.sha

    # Find the branch or tag to build.
    (commit_sha, ref) = determine_build_ref(run_parameters, get_branch_sha, get_tag_sha,
                                            default_branch)

    metadata = GithubBuildTrigger._build_metadata_for_commit(commit_sha, ref, repo)
    return self.prepare_build(metadata, is_manual=True)

  @_catch_ssl_errors
  def lookup_user(self, username):
    try:
      gh_client = self._get_client()
      user = gh_client.get_user(username)
      return {
        'html_url': user.html_url,
        'avatar_url': user.avatar_url
      }
    except GithubException:
      return None

  @_catch_ssl_errors
  def handle_trigger_request(self, request):
    # Check the payload to see if we should skip it based on the lack of a head_commit.
    payload = request.get_json()
    if payload is None:
      raise InvalidPayloadException('Missing payload')

    # This is for GitHub's probing/testing.
    if 'zen' in payload:
      raise SkipRequestException()

    # Lookup the default branch for the repository.
    if 'repository' not in payload:
      raise InvalidPayloadException("Missing 'repository' on request")

    if 'owner' not in payload['repository']:
      raise InvalidPayloadException("Missing 'owner' on repository")

    if 'name' not in payload['repository']['owner']:
      raise InvalidPayloadException("Missing owner 'name' on repository")

    if 'name' not in payload['repository']:
      raise InvalidPayloadException("Missing 'name' on repository")

    default_branch = None
    lookup_user = None
    try:
      repo_full_name = '%s/%s' % (payload['repository']['owner']['name'],
                                  payload['repository']['name'])

      gh_client = self._get_client()
      repo = gh_client.get_repo(repo_full_name)
      default_branch = repo.default_branch
      lookup_user = self.lookup_user
    except GitHubBadCredentialsException:
      logger.exception('Got GitHub Credentials Exception; Cannot lookup default branch')
    except GithubException:
      logger.exception("Got GitHub Exception when trying to start trigger %s", self.trigger.id)
      raise SkipRequestException()

    logger.debug('GitHub trigger payload %s', payload)
    metadata = get_transformed_webhook_payload(payload, default_branch=default_branch,
                                               lookup_user=lookup_user)
    prepared = self.prepare_build(metadata)

    # Check if we should skip this build.
    raise_if_skipped_build(prepared, self.config)
    return prepared