#!/usr/bin/env python3

__author__ = "Joel Pedraza"
__copyright__ = "Copyright 2014, Joel Pedraza"
__license__ = "MIT"

from hibget import requests_logging, utils
from hibget.widgets import pacman_progress_bar

import humblebundle
from humblebundle.exceptions import *

from http.cookiejar import LWPCookieJar
import requests
from requests.adapters import DEFAULT_POOLSIZE, HTTPAdapter
from requests.exceptions import RequestException
from urllib.parse import urlparse

import appdirs
import argparse
from concurrent.futures import CancelledError, ThreadPoolExecutor
from configparser import ConfigParser
from getpass import getpass
import logging
import random
import time
import os
import signal
import sys

APP_NAME = "hibget"


class Settings(object):
    def __init__(self, logger):
        self.logger = logger

        parser = self.get_parser()
        args = parser.parse_args()

        # # Simple parsed settings ##

        self.verbose = args.verbose
        self.quiet = args.quiet
        self.non_interactive = args.non_interactive or args.quiet
        self.no_download = args.no_download
        self.max_threads = args.max_threads

        ## Get the filename from the hib url ##
        try:
            parsed_url = urlparse(args.url)
            if parsed_url.scheme != 'hib' \
                    or parsed_url.path.replace('/', '') \
                    or parsed_url.params \
                    or parsed_url.query \
                    or parsed_url.fragment:
                raise ValueError("Invalid url: {}".format(args.url))
        except ValueError:
            parser.print_help(file=sys.stderr)
            self.logger.critical("\n%s is not a valid hib url.", args.url)
            sys.exit(1)
        self.filename = parsed_url.netloc

        ## The absolute path of the output file ##

        if args.output == '-':
            self.out_file = args.output
        else:
            self.out_file = os.path.abspath(args.output or self.filename)

        ## Find the config file ##

        self.config = ConfigParser()
        if args.config:
            self.config_file = os.path.abspath(args.config)
            # If the user specified a config file, it must exist. Lets not try and generate one.
            if not os.path.isfile(self.config_file):
                parser.print_help(file=sys.stderr)
                self.logger.critical("Config file does not exist")
                sys.exit(1)
            config_dir = os.path.dirname(self.config_file)
        else:
            config_dir = appdirs.user_config_dir(appname=APP_NAME, roaming=True)
            if not os.path.isdir(config_dir):
                try:
                    os.makedirs(config_dir, mode=0o755)
                except OSError as e:
                    self.logger.critical("Failed to create config dir: %s", utils.format_error(e))

            self.config_file = os.path.join(config_dir, 'config.ini')

        if not os.path.isfile(self.config_file):
            self.config['auth'] = {'username': '', 'password': ''}
            self.config['session'] = {'cookies': 'cookies.txt'}
            self.write_config()
        else:
            self.read_config()

        self.username = args.username or self.config['auth']['username'] or None
        self.password = args.password or self.config['auth']['password'] or None

        ## Find and open the cookie jar ##

        cookie_jar_file = self.config['session']['cookies']
        # Get absolute path of cookie jar (relative to config dir)
        if not os.path.isabs(cookie_jar_file):
            cookie_jar_file = os.path.join(config_dir, cookie_jar_file)
        self.cookie_jar = LWPCookieJar(cookie_jar_file)
        if not os.path.isfile(cookie_jar_file):
            self.cookie_jar.save()
        self.cookie_jar.load(filename=cookie_jar_file)

    @staticmethod
    def get_parser():
        parser = argparse.ArgumentParser(prog='hibget', description="Download a file form the Humble Store")
        parser.add_argument('url', type=str, metavar='hib://<filename>', help="The URL of the file to download")
        parser.add_argument('-c', '--config', type=str, metavar='<config-file>', help="The config file to use")
        parser.add_argument('-n', '--non-interactive', action='store_true', help="Do not display any user prompts")
        verbosity = parser.add_mutually_exclusive_group()
        verbosity.add_argument('-v', '--verbose', action='count',
                               help="""Print messages to stderr. Specify once for info, twice for debug. Debug dumps
                               raw http requests/responses on errors (may contain login credentials in plaintext)
                               """)
        verbosity.add_argument('-q', '--quiet', action='store_true', help="Suppress log messages and progress bar.")
        output = parser.add_mutually_exclusive_group()
        output.add_argument('-o', '--output', type=str, required=False, metavar='<file>', help="Write to file")
        output.add_argument('-s', '--no-download', action='store_true',
                            help="Print signed URL to stdout instead of downloading")
        parser.add_argument('--max-threads', type=int, default=8, metavar='<thread-count>',
                            help="Max number of worker threads")
        parser.add_argument('-u', '--user', type=str, dest='username', required=False, metavar='<name>',
                            help="Username used to login")
        parser.add_argument('-p', '--pass', type=str, dest='password', required=False, metavar='<passowrd>',
                            help="Password used to login")
        return parser

    def read_config(self):
        try:
            self.config.read(self.config_file)
        except IOError as e:
            self.logger.critical("Failed to read config file: %s", utils.format_error(e))
            sys.exit(1)

    def write_config(self):
        try:
            with open(self.config_file, 'w') as fd:
                self.config.write(fd)
        except IOError as e:
            self.logger.critical("Failed to initialize default config file: %s", utils.format_error(e))
            sys.exit(1)

    def ensure_creds(self):
        if self.non_interactive:
            if not self.username or self.password:
                raise ValueError("Invalid login credentials")
            return

        if not self.username:
            self.username = input("User name: ")

        if not self.password:
            self.password = getpass("Password: ")

    def clear_creds(self):
        self.username = None
        self.password = None

    def prompt_store_creds(self):
        if self.non_interactive:
            return

        if (self.username != self.config['auth']['username'] or self.password != self.config['auth']['password']) \
                and utils.input_yes_no("Store credentials?", default=False):
            self.config['auth']['username'] = self.username
            self.config['auth']['password'] = self.password
            self.write_config()


class HibGet:
    def __init__(self, max_workers=8):
        self.logger = logging.getLogger(__name__)
        self.settings = Settings(self.logger)

        self.client = humblebundle.HumbleApi()

        self.setup_logger()

        self.client.session.cookies = self.settings.cookie_jar

        self.executor = ThreadPoolExecutor(max_workers=max_workers)
        # set connection pool size equal to max_workers if needed
        if max_workers > DEFAULT_POOLSIZE:
            self.client.session.mount('http://', HTTPAdapter(pool_connections=max_workers, pool_maxsize=max_workers))
            self.client.session.mount('https://', HTTPAdapter(pool_connections=max_workers, pool_maxsize=max_workers))

    def setup_logger(self):
        root_logger = logging.getLogger()
        if self.settings.quiet:
            root_logger.setLevel(1000)
            return

        # disable verbose message from requests module
        requests_logger = logging.getLogger('requests')
        requests_logger.setLevel(logging.ERROR)

        humble_logger = humblebundle.logger

        sh = logging.StreamHandler()
        humble_logger.addHandler(sh)
        self.logger.addHandler(sh)

        level = logging.ERROR
        if self.settings.verbose:
            if self.settings.verbose == 1:
                level = logging.INFO
            elif self.settings.verbose >= 2:
                level = logging.DEBUG
                # enable verbose message from requests module
                requests_logger.setLevel(logging.DEBUG)
                # dump raw requests and responses
                sh.setFormatter(requests_logging.RequestsFormatter())

        root_logger.setLevel(level)
        humble_logger.setLevel(level)
        self.logger.setLevel(level)
        sh.setLevel(level)

    def find_url(self, order_list):
        """
        Get the url for a given filename. Downloads each order in a separate thread, blocking until a match is found or
        all orders have been processed.
        :rtype : str
        """

        self.logger.info("Processing orders")
        pending = set()
        signed_url = None
        random.shuffle(order_list)  # amortize lookup time

        # Perform on main thread after each task completes
        def foreground_callback(t):
            pending.remove(t)
            try:
                t.result()
            except CancelledError:
                pass
            except RequestException as e:
                oid = urlparse(e.request.url).path.split('/')[-1]
                self.logger.error("Failed to download order %s: %s", oid, utils.format_error(e))
                self.logger.debug(e)
                # Other exceptions should bubble up

        # Perform on background thread after each task completes
        def background_callback(order_):
            nonlocal signed_url
            for subproduct in order_.subproducts:
                for download in subproduct.downloads:
                    for struct in download.download_struct:
                        if struct.url.web:
                            url = urlparse(struct.url.web)
                            filename = url.path.split('/')[-1]
                            if filename == self.settings.filename:
                                signed_url = struct.url.web

        # Start tasks
        for order in order_list:
            task = self.executor.submit(self.client.order, order.gamekey, callback=background_callback)
            pending.add(task)
            task.add_done_callback(foreground_callback)

        # A crude event loop that waits for the target_filename to be found, or for all tasks to finish
        while pending:
            if signed_url:
                self.logger.debug("%s found. Cancelling %d pending orders", self.settings.filename, len(pending))
                for task in pending.copy():
                    task.cancel()
                return signed_url
            time.sleep(0.5)

        raise HumbleException("Filename not found")

    def download_file(self, url):
        chunk_size = 4096
        resp = requests.get(url, stream=True)

        content_length = int(resp.headers['Content-Length'])
        filename = urlparse(resp.url).path.split('/')[-1]

        prog_fd = os.devnull if self.settings.quiet else sys.stderr
        progress = pacman_progress_bar(title=filename, maxval=content_length, fd=prog_fd).start()

        with utils.smart_open(self.settings.out_file, mode='wb') as fd:
            completed = 0
            for chunk in resp.iter_content(chunk_size):
                fd.write(chunk)
                completed += len(chunk)
                progress.update(completed)

        progress.finish()

    def login(self, attempt=0, max_attempts=3):
        if attempt >= max_attempts:
            self.logger.critical("Login failed %d times. Quitting.", attempt)
            sys.exit(1)

        attempt += 1

        # prompt for login creds if needed
        try:
            self.settings.ensure_creds()
        except ValueError as e:
            self.logger.critical(str(e))
            sys.exit(1)

        try:
            self.client.login(self.settings.username, self.settings.password)
        except HumbleCredentialException as e:
            self.logger.error(str(e))
            self.settings.clear_creds()
            self.login(attempt=attempt, max_attempts=max_attempts)
        except HumbleAuthenticationException as e:
            if e.captcha_required:
                self.logger.critical("CAPTCHA required, not yet implemented. Sorry!")
            elif e.authy_required:
                self.logger.critical("Authy one-time-pass required, not yet implemented. Sorry!")
            else:
                self.logger.critical("Login failed: %s", utils.format_error(e))
            self.logger.debug(e)
            sys.exit(1)
        except RequestException as e:
            self.logger.critical("Connection failed while logging in: %s", utils.format_error(e))
            self.logger.debug(e)
            sys.exit(1)

        self.client.session.cookies.save()
        self.logger.info("Login successful")
        self.settings.prompt_store_creds()
        return True

    def get_order_list(self):
        try:
            return self.client.order_list()
        except HumbleAuthenticationException as e:
            self.logger.error("Failed to get order list: %s", utils.format_error(e))
            self.login()
            return self.get_order_list()

    def run(self):
        try:
            self._start()
        except SystemExit:
            pass
        except KeyboardInterrupt:
            sys.exit(1)
        except Exception as e:
            self.logger.critical(str(e))
            self.logger.debug(e)
            sys.exit(1)
        finally:
            self.cleanup()

    def _start(self):
        try:
            order_list = self.get_order_list()
        except RequestException as e:
            self.logger.critical("Failed to get order list: %s", utils.format_error(e))
            self.logger.debug(e)
            sys.exit(1)

        try:
            signed_url = self.find_url(order_list)
        except HumbleException as e:
            self.logger.critical("Failed to find {filename}: %s".format(filename=self.settings.filename),
                                 utils.format_error(e))
            sys.exit(1)

        if self.settings.no_download:
            print(signed_url)
        else:
            self.download_file(signed_url)

    def cleanup(self):
        self.executor.shutdown()


if __name__ == '__main__':
    hibget = HibGet()

    def handler(signum, frame):
        hibget.cleanup()

    signal.signal(signal.SIGTERM, handler)

    hibget.run()
