"""``redmine-sync`` public API."""

from datetime import datetime
from functools import partial
import json
from multiprocessing.dummy import Pool
from os.path import exists, join
from urllib.parse import urljoin

from logbook import Logger
from lxml import etree
import requests


__author__ = "Vivien Maisonneuve"
__copyright__ = "2017, Vivien Maisonneuve"
__description__ = "Store Redmine issues as a bunch of XML files"
__email__ = 'v.maisonneuve@gmail.com'
__license__ = 'LGPL'
__uri__ = 'https://github.com/vivienm/redmine-sync'
__version__ = '0.3.3.dev0'


logger = Logger(__name__)


class Redmine:
    """Redmine client class.

    If the Redmine server requires authentication, it can be passed to the
    *auth* argument, most commonly as a pair of strings ``(username,
    password)``.  Other available authentication methods are described in the
    `Requests library documentation
    <http://docs.python-requests.org/en/stable/user/authentication/>`_.

    By default, SSL verification is enabled and a :class:`ssl.SSLError` will be
    raised when the certification cannot be verified. The argument *verify* can
    be passed the path to a CA_BUNDLE file or directory with certificates of
    trusted CAs.  SSL certificate verification can also be ignored by setting
    *verify* to ``False``.
    """

    def __init__(self, url, auth=None, verify=True, timeout=10, zfill=5):
        """Initialize the Redmine client."""
        self.url = url.rstrip('/') + '/'
        self._session = requests.Session()
        self._session.auth = auth
        self._session.verify = verify
        self.timeout = timeout
        self.zfill = zfill

    def close(self):
        """Close connections to the Redmine server."""
        self._session.close()

    def sync_issues(self, dirpath, num_threads=16):
        """Download new or updated issues to directory *dirpath*.

        The number of concurrent download threads can be adjusted in the
        argument *num_threads*.
        """
        start_dt = datetime.utcnow()
        syncstate_filepath = join(dirpath, self._syncstate_filename)
        sync_dt = self._get_sync_dt(syncstate_filepath)
        issue_ids = self._updated_issue_ids(sync_dt)
        self._download_issues(dirpath, issue_ids, num_threads)
        self._set_sync_dt(start_dt, syncstate_filepath)

    _syncstate_filename = '.syncstate.json'

    def _get_sync_dt(self, syncstate_filepath):
        if not exists(syncstate_filepath):
            return datetime.utcfromtimestamp(0)
        else:
            with open(syncstate_filepath) as sync_fileobj:
                sync_data = json.load(sync_fileobj)
            return datetime.fromtimestamp(sync_data['timestamp'])

    def _set_sync_dt(self, sync_dt, syncstate_filepath):
        sync_data = {'timestamp': sync_dt.timestamp()}
        with open(syncstate_filepath, 'w') as sync_fileobj:
            json.dump(sync_data, sync_fileobj)

    def _updated_issue_ids(self, start_dt):
        logger.info("Fetch updated issues since {:%Y-%m-%d %H:%M:%SZ}",
                    start_dt)
        issue_ids = set()
        while True:
            offset = len(issue_ids)
            logger.debug("Get issues with offset {}", offset)
            tree = self._get_xml('issues.xml', {
                'status_id': '*',
                'sort': 'updated_on',
                'updated_on': '>={:%Y-%m-%dT%H:%M:%SZ}'.format(start_dt),
                'limit': 100,
                'offset': offset,
            })
            if tree.find('./issue') is None:
                break
            issue_ids.update(
                int(issue_tree.text)
                for issue_tree in tree.iterfind('./issue/id')
            )
        num_issues = len(issue_ids)
        logger.info("Found {} updated issue{}",
                    num_issues, 's' if num_issues >= 2 else '')
        return sorted(issue_ids)

    def _get_xml(self, path, params=None):
        url = urljoin(self.url, path)
        resp = self._session.get(url, params=params, timeout=self.timeout)
        resp.raise_for_status()
        tree = etree.fromstring(resp.content)
        return tree

    def _download_issues(self, dirpath, issue_ids, num_threads):
        if len(issue_ids) == 0:
            return
        pool = Pool(min(num_threads, len(issue_ids)))
        pool.map(partial(self._download_issue, dirpath), issue_ids)
        pool.close()
        pool.join()

    def _download_issue(self, dirpath, issue_id):
        issue_tree = self._get_xml('issues/{}.xml'.format(issue_id), {
            'include': 'journals',
        })
        issue_filestem = str(issue_id).zfill(self.zfill)
        issue_filepath = join(dirpath, '{}.xml'.format(issue_filestem))
        with open(issue_filepath, 'wb') as issue_fileobj:
            issue_fileobj.write(etree.tostring(issue_tree, pretty_print=True))
        logger.info("Downloaded issue {}", issue_id)
