import json
import logging

import getpass

import sys

import copy
from requests import request, RequestException
from amazon_dash._compat import JSONDecodeError
from amazon_dash.exceptions import SecurityException, InvalidConfig, ExecuteError
from ._compat import urlparse, subprocess

EXECUTE_SHELL_PARAM = '-c'
ROOT_USER = 'root'

CONTENT_TYPE_METHODS = ['post', 'put', 'patch']
CONTENT_TYPE_ALIASES = {
    'form': 'application/x-www-form-urlencoded',
    'json': 'application/json',
    'plain': 'text/plain',
}

logger = logging.getLogger('amazon-dash')

def get_shell(name):
    """Absolute path to command

    :param str name: command
    :return: command args
    :rtype: list
    """
    if name.startswith('/'):
        return [name]
    return ['/usr/bin/env', name]

def run_as_cmd(cmd, user, shell='bash'):
    """Get the arguments to execute a command as a user

    :param str cmd: command to execute
    :param user: User for use
    :param shell: Bash, zsh, etc.
    :return: arguments
    :rtype: list
    """
    to_execute = get_shell(shell) + [EXECUTE_SHELL_PARAM, cmd]
    if user == 'root':
        return to_execute
    return ['sudo', '-s', '--set-home', '-u', user] + to_execute

def execute_cmd(cmd, cwd=None, timeout=5):
    """Excecute command on thread

    :param cmd: Command to execute
    :param cwd: current working directory
    :return: None
    """
    p = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    try:
        p.wait(timeout=timeout)
    except subprocess.TimeoutExpired:
        return None
    else:
        stdout, stderr = p.stdout.read(), p.stderr.read()
        if sys.version_info >= (3,):
            stdout, stderr = stdout.decode('utf-8', errors='ignore'), stderr.decode('utf-8', errors='ignore')
        if p.returncode:
            raise ExecuteError('Error running command {}: The error code {} has returned. Stderr: {}'.format(
                ' '.join(cmd), p.returncode, stderr
            ))
        else:
            return stdout, stderr

def execute_over_ssh(cmd, ssh, cwd=None, shell='bash'):
    """Excecute command on remote machine using SSH

    :param cmd: Command to execute
    :param ssh: Server to connect. Port is optional
    :param cwd: current working directory
    :return: None
    """
    port = None
    parts = ssh.split(':', 1)
    if len(parts) > 1 and not parts[1].isdigit():
        raise InvalidConfig(extra_body='Invalid port number on ssh config: {}'.format(parts[1]))
    elif len(parts) > 1:
        port = parts[1]
    quoted_cmd = ' '.join([x.replace("'", """'"'"'""") for x in cmd.split(' ')])
    remote_cmd = ' '.join([
        ' '.join(get_shell(shell)), # /usr/bin/env bash
        ' '.join([EXECUTE_SHELL_PARAM, "'", ' '.join((['cd', cwd, ';'] if cwd else []) + [quoted_cmd]), "'"])],
    )
    return ['ssh', parts[0]] + (['-p', port] if port else []) + ['-C'] + [remote_cmd]

class Execute(object):
    """Execute base class

    """

    def __init__(self, name, data):
        """

        :param str name: name or mac address
        :param data: data on device section
        """
        self.name = name
        self.data = data

    def validate(self):
        """Check self.data. Raise InvalidConfig on error

        :return: None
        """
        raise NotImplementedError

    def execute(self, root_allowed=False):
        """Execute using self.data

        :param bool root_allowed: Only used for ExecuteCmd
        :return:
        """
        raise NotImplementedError

class ExecuteCmd(Execute):
    """Execute systemd command
    """

    def __init__(self, name, data):
        """

        :param str name: name or mac address
        :param data: data on device section
        """
        super(ExecuteCmd, self).__init__(name, data)
        self.user = data.get('user', getpass.getuser())
        self.cwd = data.get('cwd')

    def validate(self):
        """Check self.data. Raise InvalidConfig on error

        :return: None
        """
        return

    def execute(self, root_allowed=False):
        """Execute using self.data

        :param bool root_allowed: Allow execute as root commands
        :return:
        """
        if self.user == ROOT_USER and not root_allowed and not self.data.get('ssh'):
            raise SecurityException('For security, execute commands as root is not allowed. '
                                    'Use --root-allowed to allow executing commands as root. '
                                    ' It is however recommended to add a user to the configuration '
                                    'of the device (device: {})'.format(self.name))
        if self.data.get('user') and self.data.get('ssh'):
            raise InvalidConfig('User option is unsupported in ssh mode. The ssh user must be defined in '
                                'the ssh option. For example: user@machine')
        if self.data.get('ssh'):
            cmd = execute_over_ssh(self.data['cmd'], self.data['ssh'], self.data.get('cwd'))
            output = execute_cmd(cmd)
        else:
            cmd = run_as_cmd(self.data['cmd'], self.user)
            output = execute_cmd(cmd, self.data.get('cwd'))
        if output:
            return output[0]

class ExecuteUrl(Execute):
    """Call a url
    """

    def validate(self):
        """Check self.data. Raise InvalidConfig on error

        :return: None
        """
        if (self.data.get('content-type') or self.data.get('body')) and \
                self.data.get('method', '').lower() not in CONTENT_TYPE_METHODS:
            raise InvalidConfig(
                extra_body='The body/content-type option only can be used with the {} methods. The device is {}. '
                           'Check the configuration file.'.format(', '.join(CONTENT_TYPE_METHODS), self.name)
            )
        self.data['content-type'] = CONTENT_TYPE_ALIASES.get(self.data.get('content-type'),
                                                             self.data.get('content-type'))
        form_type = CONTENT_TYPE_ALIASES['form']
        if self.data.get('body') and (self.data.get('content-type') or form_type) == form_type:
            try:
                self.data['body'] = json.loads(self.data['body'])
            except JSONDecodeError:
                raise InvalidConfig(
                    extra_body='Invalid JSON body on {} device.'.format(self.name)
                )

    def execute(self, root_allowed=False):
        """Execute using self.data

        :param bool root_allowed: Only used for ExecuteCmd
        :return:
        """
        kwargs = {'stream': True, 'timeout': 15,
                  'headers': self.data.get('headers', {})}
        if self.data.get('content-type'):
            kwargs['headers']['content-type'] = self.data['content-type']
        if self.data.get('body'):
            kwargs['data'] = self.data['body']
        if self.data.get('auth'):
            kwargs['auth'] = tuple(self.data['auth'].split(':', 1))
        try:
            resp = request(self.data.get('method', 'get').lower(), self.data['url'],
                           verify=self.data.get('verify', True),
                           **kwargs)
        except RequestException as e:
            raise ExecuteError('Exception on request to {}: {}'.format(self.data['url'], e))
        if resp.status_code >= 400:
            raise ExecuteError('"{}" return code {}.'.format(self.data['url'], resp.status_code))
        data = resp.raw.read(1000, decode_content=True)
        if sys.version_info >= (3,):
            data = data.decode('utf-8', errors='ignore')
        return data

class ExecuteUrlServiceBase(ExecuteUrl):
    """Base class to create services execute classes
    """
    default_url = None  #: default url to call
    default_headers = None  #: default HTTP headers to send
    default_content_type = 'application/json'  #: default content type to send
    default_method = 'GET'  #: default HTTP method
    default_body = None  #: default body to send

    def __init__(self, name, data):
        """

        :param str name: name or mac address
        :param data: data on device section
        """
        super(ExecuteUrlServiceBase, self).__init__(name, data)
        self.data['url'] = self.get_url()
        self.data['headers'] = self.get_headers()
        self.data['content-type'] = self.get_content_type()
        self.data['method'] = self.get_method()
        self.data['body'] = self.get_body()

    def get_url(self):
        """Get url to call. By default default_url

        :return: url
        :rtype: str
        """
        return self.default_url

    def get_method(self):
        """Get HTTP method. By default default_method

        :return: HTTP method
        :rtype: str
        """
        return self.default_method

    def get_content_type(self):
        """Get HTTP content type to send. By default default_content_type

        :return: HTTP content type
        :rtype: str
        """
        return self.default_content_type

    def get_headers(self):
        """Get HTTP Headers to send. By default default_headers

        :return: HTTP Headers
        :rtype: dict
        """
        headers = copy.copy(self.default_headers or {})
        headers.update(self.data.get('headers') or {})
        return headers

    def get_body(self):
        """Get body to send. By default default_body

        :return: body content
        :rtype: str
        """
        return self.default_body

class ExecuteOwnApiBase(ExecuteUrlServiceBase):
    execute_name = None
    default_protocol = 'http'
    default_port = 0
    default_method = 'POST'

    def get_url(self):
        """API url

        :return: url
        :rtype: str
        """
        url = self.data[self.execute_name]
        parsed = urlparse(url)
        if not parsed.scheme:
            url = '{}://{}'.format(self.default_protocol, url)
        if not url.split(':')[-1].isalnum():
            url += ':{}'.format(self.default_port)
        return url

    def get_body(self):
        """Return "data" value on self.data

        :return: data to send
        :rtype: str
        """
        if self.default_body:
            return self.default_body
        data = self.data.get('data')
        if isinstance(data, dict):
            return json.dumps(data)
        return data

class ExecuteHomeAssistant(ExecuteOwnApiBase):
    """Send Home Assistant event

    https://home-assistant.io/developers/rest_api/#post-apieventsltevent_type
    """
    execute_name = 'homeassistant'
    default_port = 8123

    def get_url(self):
        """Home assistant url

        :return: url
        :rtype: str
        """
        url = super(ExecuteHomeAssistant, self).get_url()
        if not self.data.get('event'):
            raise InvalidConfig(extra_body='Event option is required for HomeAsistant on {} device.'.format(self.name))
        url += '/api/events/{}'.format(self.data['event'])
        return url

    def get_headers(self):
        headers = {}
        if 'access_token' in self.data:
            headers['Authorization'] = 'Bearer {0}'.format(self.data['access_token'])
        elif 'access' in self.data:
            headers['x-ha-access'] = self.data['access']

        return headers

class ExecuteOpenHab(ExecuteOwnApiBase):
    """Send Open Hab event

    """
    default_content_type = 'text/plain'
    execute_name = 'openhab'
    default_port = 8080

    def __init__(self, name, data):
        super(ExecuteOpenHab, self).__init__(name, data)
        self.data['headers'] = dict(self.data.get('headers') or {}, **{'Accept': 'application/json'})

    def get_url(self):
        """Open Hab url

        :return: url
        :rtype: str
        """
        url = super(ExecuteOpenHab, self).get_url()
        if not self.data.get('item'):
            raise InvalidConfig(extra_body='Item option is required for Open Hab on {} device.'.format(self.name))
        url += '/rest/items/{}'.format(self.data['item'])
        return url

    def get_body(self):
        return self.data.get('state', 'ON')

class ExecuteIFTTT(ExecuteOwnApiBase):
    """Send IFTTT Webhook event.
    """
    execute_name = 'ifttt'
    url_pattern = 'https://maker.ifttt.com/trigger/{event}/with/key/{key}'

    def get_url(self):
        """IFTTT Webhook url

        :return: url
        :rtype: str
        """
        if not self.data[self.execute_name]:
            raise InvalidConfig(extra_body='Value for IFTTT is required on {} device. Get your key here: '
                                           'https://ifttt.com/services/maker_webhooks/settings'.format(self.name))
        if not self.data.get('event'):
            raise InvalidConfig(extra_body='Event option is required for IFTTT on {} device. '
                                           'You define the event name when creating a Webhook '
                                           'applet'.format(self.name))
        url = self.url_pattern.format(event=self.data['event'], key=self.data[self.execute_name])
        return url


import click

from amazon_dash.scan import scan_devices

AMAZON_DEVICES = [
    'F0:D2:F1',
    '88:71:E5',
    'FC:A1:83',
    'F0:27:2D',
    '74:C2:46',
    '68:37:E9',
    '78:E1:03',
    '38:F7:3D',
    '50:DC:E7',
    'A0:02:DC',
    '0C:47:C9',
    '74:75:48',
    'AC:63:BE',
    'FC:A6:67',
    '18:74:2E',
    '00:FC:8B',
    'FC:65:DE',
    '6C:56:97',
    '44:65:0D',
    '50:F5:DA',
    '68:54:FD',
    '40:B4:CD',
    '00:71:47',
    '4C:EF:C0',
    '84:D6:D0',
    '34:D2:70',
    'B4:7C:9C',
    'F0:81:73',
]
"""Amazon Dash Mac Devices. Source: https://standards.ieee.org/develop/regauth/oui/oui.csv

Snippet for Re-generate this list:

>>> import csv
>>> print('\n'.join([':'.join([row[1][i:i+2] for i in range(0, len(row[1]), 2)])
        for row in csv.reader(open('oui.csv')) if row[2] == 'Amazon Technologies Inc.']))
"""

BANNED_DEVICES = ['00:00:00:00:00:00']
"""These mac addresses will not be considered valid results on discovery.
"""

HELP = """\
The discovery command lists the devices that are connected in your network. \
Each device will only be listed once. After executing this command wait approximately \
10 seconds before pressing the Amazon Dash button. After pressing the button, the Mac \
address of the button will immediately appear on the screen. Remember the address to be \
able to create the configuration file.\
"""

mac_id_list = []
"""Mac addresses already known. Mac addresses only appear once.
"""

def pkt_text(pkt):
    """Return source mac address for this Scapy Packet

    :param scapy.packet.Packet pkt: Scapy Packet
    :return: Mac address. Include (Amazon Device) for these devices
    :rtype: str
    """
    if pkt.src.upper() in BANNED_DEVICES:
        body = ''
    elif pkt.src.upper()[:8] in AMAZON_DEVICES:
        body = '{} (Amazon Device)'.format(pkt.src)
    else:
        body = pkt.src
    return body

def discovery_print(pkt):
    """Scandevice callback. Register src mac to avoid src repetition.
    Print device on screen.

    :param scapy.packet.Packet pkt: Scapy Packet
    :return: None
    """
    if pkt.src in mac_id_list:
        return
    mac_id_list.append(pkt.src)
    text = pkt_text(pkt)
    click.secho(text, fg='magenta') if 'Amazon' in text else click.echo(text)

def discover(interface=None):
    """Print help and scan devices on screen.

    :return: None
    """
    click.secho(HELP, fg='yellow')
    scan_devices(discovery_print, lfilter=lambda d: d.src not in mac_id_list, iface=interface)


import time
from collections import defaultdict

import threading

from amazon_dash.config import Config
from amazon_dash.confirmations import get_confirmation
from amazon_dash.exceptions import InvalidConfig, InvalidDevice
from amazon_dash.execute import logger, ExecuteCmd, ExecuteUrl, ExecuteHomeAssistant, ExecuteOpenHab, ExecuteIFTTT
from amazon_dash.scan import scan_devices

DEFAULT_DELAY = 10
"""
On seconds. By default, 10 seconds. Minimum time that must pass between pulsations of the Amazon Dash button.
"""

EXECUTE_CLS = {
    'cmd': ExecuteCmd,
    'url': ExecuteUrl,
    'homeassistant': ExecuteHomeAssistant,
    'openhab': ExecuteOpenHab,
    'ifttt': ExecuteIFTTT,
}
"""
Execute classes registered.
"""

last_execution = defaultdict(lambda: 0)
"""
Last time a device was executed. Value on unix time.
"""

class Device(object):
    """Set the execution method for the device
    """
    execute_instance = None  #: Execute cls instance

    def __init__(self, src, data=None, config=None):
        """

        :param str src: Mac address
        :param data: device data
        """
        data = data or {}
        config = config or {}

        if isinstance(src, Device):
            src = src.src
        self.src = src.lower()
        self.data = data
        execs = [cls(self.name, data) for name, cls in EXECUTE_CLS.items() if name in self.data]
        if len(execs) > 1:
            raise InvalidConfig(
                extra_body='There can only be one method of execution on a device. The device is {}. '.format(self.name)
            )
        elif len(execs):
            self.execute_instance = execs[0]
            self.execute_instance.validate()
        self.confirmation = get_confirmation(src, data, config.get('confirmations', {}))

    @property
    def name(self):
        """Name on self.data or mac address

        :return: name
        :rtype: str
        """
        return self.data.get('name', self.src)

    def execute(self, root_allowed=False):
        """Execute this device

        :param bool root_allowed: Only used for ExecuteCmd
        :return: None
        """
        logger.debug('%s device executed (mac %s)', self.name, self.src)
        if not self.execute_instance:
            msg = '%s: There is not execution method in device conf.'
            logger.warning(msg, self.name)
            self.send_confirmation(msg % self.name, False)
            return
        try:
            result = self.execute_instance.execute(root_allowed)
        except Exception as e:
            self.send_confirmation('Error executing the device {}: {}'.format(self.name, e), False)
            raise
        else:
            result = 'The {} device has been started and is running right now'.format(self.name) \
                if result is None else result
            result = result or 'The {} device has been executed successfully'.format(self.name)
            self.send_confirmation(result)
        return result

    def send_confirmation(self, message, success=True):
        """Send success or error message to configured confirmation

        :param str message: Body message to send
        :param bool success: Device executed successfully to personalize message
        :return: None
        """
        message = message.strip()
        if not self.confirmation:
            return
        try:
            self.confirmation.send(message, success)
        except Exception as e:
            logger.warning('Error sending confirmation on device {}: {}'.format(self.name, e))

class Listener(object):
    """Start listener daemon for execute on button press
    """

    root_allowed = False  #: Only used for ExecuteCmd

    def __init__(self, config_path, ignore_perms=False):
        """

        :param str config_path: Path to config file
        """
        self.config = Config(config_path, ignore_perms)
        self.settings = self.config.get('settings', {})
        self.devices = {key.lower(): Device(key, value, self.config)
                                     for key, value in self.config['devices'].items()}
        assert len(self.devices) == len(self.config['devices']), "Duplicate(s) MAC(s) on devices config."

    def on_push(self, device):
        """Press button. Check DEFAULT_DELAY.

        :param scapy.packet.Packet device: Scapy packet
        :return: None
        """
        src = device.src.lower()
        if last_execution[src] + self.settings.get('delay', DEFAULT_DELAY) > time.time():
            return
        last_execution[src] = time.time()
        self.execute(device)

    def execute(self, device):
        """Execute a device. Used if the time between executions is greater than DEFAULT_DELAY

        :param scapy.packet.Packet device: Scapy packet
        :return: None
        """
        src = device.src.lower()
        device = self.devices[src]
        threading.Thread(target=device.execute, kwargs={
            'root_allowed': self.root_allowed
        }).start()

    def run(self, root_allowed=False):
        """Start daemon mode

        :param bool root_allowed: Only used for ExecuteCmd
        :return: loop
        """
        self.root_allowed = root_allowed
        scan_devices(self.on_push, lambda d: d.src.lower() in self.devices, self.settings.get('interface'))

def test_device(device, file, root_allowed=False):
    """Test the execution of a device without pressing the associated button

    :param str device: mac address
    :param str file: config file
    :param bool root_allowed: only used for ExecuteCmd
    :return: None
    """
    config = Config(file)
    config.read()
    if not device in config['devices']:
        raise InvalidDevice('Device {} is not in config file.'.format(device))
    logger.info(Device(device, config['devices'][device], config).execute(root_allowed))


import logging

from amazon_dash.exceptions import SocketPermissionError

logging.getLogger("scapy.runtime").setLevel(logging.ERROR)
from scapy.all import *

try:
    PermissionError
except NameError:
    import socket
    PermissionError = socket.error

def scan_devices(fn, lfilter, iface=None):
    """Sniff packages

    :param fn: callback on packet
    :param lfilter: filter packages
    :return: loop
    """
    try:
        sniff(prn=fn, store=0,
              # filter="udp",
              filter="arp or (udp and src port 68 and dst port 67 and src host 0.0.0.0)",
              lfilter=lfilter, iface=iface)
    except PermissionError:
        raise SocketPermissionError


import os

from selenium.webdriver.remote.webdriver import WebDriver

import selene.config
import selene.driver
import selene.factory
from selene import helpers
from selene.common.none_object import NoneObject
from selene.elements import SeleneElement, SeleneCollection
from selene.wait import wait_for

def quit_driver():
    driver().quit()

def quit():
    quit_driver()

def close():
    driver().close()

def set_driver(webdriver):
    # type: (WebDriver) -> None
    if selene.factory.is_another_driver(webdriver):
        selene.factory.kill_all_started_drivers()
    selene.factory.set_shared_driver(webdriver)

def driver():
    # type: () -> WebDriver
    return selene.factory.ensure_driver_started(selene.config.browser_name)

def open_url(absolute_or_relative_url):
    """
    Loads a web page in the current browser session.
    :param absolgenerateute_or_relative_url:
        an absolute url to web page in case of config.base_url is not specified,
        otherwise - relative url correspondingly

    :Usage:
        open_url('http://mydomain.com/subpage1')
        open_url('http://mydomain.com/subpage2')
        # OR
        config.base_url = 'http://mydomain.com'
        open_url('/subpage1')
        open_url('/subpage2')
    """
    # todo: refactor next line when app_host is removed
    base_url = selene.config.app_host if selene.config.app_host else selene.config.base_url
    driver().get(base_url + absolute_or_relative_url)

def element(css_selector_or_by):
    return SeleneElement.by_css_or_by(css_selector_or_by, selene.driver._shared_driver)

def elements(css_selector_or_by):
    return SeleneCollection.by_css_or_by(css_selector_or_by, selene.driver._shared_driver)

def all(css_selector_or_by):
    return elements(css_selector_or_by)

_latest_screenshot = NoneObject("selene.browser._latest_screenshot")

def take_screenshot(path=None, filename=None):
    if not path:
        path = selene.config.reports_folder
    if not filename:
        filename = "screen_{id}".format(id=next(selene.config.counter))

    screenshot_path = helpers.take_screenshot(driver(), path, filename)

    global _latest_screenshot
    _latest_screenshot = screenshot_path

    return screenshot_path

def latest_screenshot():
    return _latest_screenshot

# todo: consider adding aliases, like: wait_until, wait_brhwser_to
def wait_to(webdriver_condition, timeout=None, polling=None):
    if timeout is None:
        timeout = selene.config.timeout
    if polling is None:
        polling = selene.config.poll_during_waits

    return wait_for(driver(), webdriver_condition, timeout, polling)

def should(webdriver_condition, timeout=None, polling=None):
    return wait_to(webdriver_condition, timeout, polling)

def execute_script(script, *args):
    return driver().execute_script(script, *args)

def title():
    return driver().title


# Copyright (c) 2013, 2014 Erik Hetzner
# Portions Copyright (c) 2016 James S Blachly, MD
#
# This file is part of ledger-autosync
#
# ledger-autosync is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# ledger-autosync is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ledger-autosync. If not, see
# <http://www.gnu.org/licenses/>.

from decimal import Decimal
import re
from ofxparse.ofxparse import Transaction as OfxTransaction, InvestmentTransaction
from ledgerautosync import EmptyInstitutionException
import datetime
import hashlib

AUTOSYNC_INITIAL = "autosync_initial"
ALL_AUTOSYNC_INITIAL = "all.%s" % (AUTOSYNC_INITIAL)
UNKNOWN_BANK_ACCOUNT = "Assets:Unknown"

class EasyEquality(object):
    def __eq__(self, other):
        if isinstance(other, self.__class__):
            return self.__dict__ == other.__dict__
        else:
            return False

    def __ne__(self, other):
        return not self.__eq__(other)

class SecurityList(object):
    """
    The SecurityList represents the OFX <SECLIST>...</SECLIST>
    and holds securities present in the OFX records

    <SECINFO>...</SECINFO> as implemented by OFXparse only includes:
    {memo, name, ticker, uniqueid}
    Unfortunately does not provide uniqueid_type or currency

    It is iterable, and also provides lookup table (LUT) functionality
    provides __next__() for Py3
    """

    def __init__(self, ofx):
        securities = []
        if hasattr(ofx, 'security_list') and ofx.security_list is not None:
            securities = ofx.security_list

        self.cusip_lut = dict()
        self.ticker_lut = dict()

        self._iter = iter(securities)
        self.securities = securities
        if len(securities) == 0:
            return

        # index
        for sec in securities:
            # unfortunately OFXparse does not currently implement
            # security.uniqueid_type so I am presuming here
            if sec.uniqueid:
                self.cusip_lut[sec.uniqueid] = sec
            if sec.ticker:
                self.ticker_lut[sec.ticker] = sec
            # This indexing strategy (whereby I index the object instead of
            # the inverse value (e.g. ticker symbol) directly has a flaw
            # in that an OFX file could define a security list section and
            # list CUSIPs without ticker property, or the converse

    def __iter__(self):
        return self

    def __next__(self):
        return next(self._iter)

    def __len__(self):
        return len(self.securities)

    # one possibility is to just implement __getitem__(),
    # however since OFXparse does not implement securitylist.security.uniqueid_type
    # I'll have no idea if what I am seeing is a CUSISP
    # unless I look it up specifically as a CUSIP (and it exists)
    def find_cusip(self, cusip):
        if cusip in self.cusip_lut:
            return self.cusip_lut[cusip]
        else:
            return None

    def find_ticker(self, ticker):
        if ticker in self.ticker_lut:
            return self.ticker_lut[ticker]
        else:
            return None

class Transaction(object):
    def __init__(
            self,
            date,
            payee,
            postings,
            cleared=False,
            metadata={},
            aux_date=None):
        self.date = date
        self.aux_date = aux_date
        self.payee = payee
        self.postings = postings
        self.metadata = metadata
        self.cleared = cleared

    def format(self, indent=4, assertions=True):
        retval = ""
        cleared_str = " "
        if self.cleared:
            cleared_str = " * "
        aux_date_str = ""
        if self.aux_date is not None:
            aux_date_str = "=%s" % (self.aux_date.strftime("%Y/%m/%d"))
        retval += "%s%s%s%s\n" % (self.date.strftime("%Y/%m/%d"),
                                  aux_date_str, cleared_str, self.payee)
        for k in sorted(self.metadata.keys()):
            retval += "%s; %s: %s\n" % (" " * indent, k, self.metadata[k])
        for posting in self.postings:
            retval += posting.format(indent, assertions)
        return retval

class Posting(object):
    def __init__(
            self,
            account,
            amount,
            asserted=None,
            unit_price=None,
            metadata={}):
        self.account = account
        self.amount = amount
        self.asserted = asserted
        self.unit_price = unit_price
        self.metadata = metadata

    def format(self, indent=4, assertions=True):
        space_count = 65 - indent - \
            len(self.account) - len(self.amount.format())
        if space_count < 2:
            space_count = 2
        retval = "%s%s%s%s" % (" " * indent,
                               self.account,
                               " " * space_count,
                               self.amount.format())
        if assertions and self.asserted is not None:
            retval = "%s = %s" % (retval, self.asserted.format())
        if self.unit_price is not None:
            retval = "%s @ %s" % (retval, self.unit_price.format())
        retval += "\n"
        for k in sorted(self.metadata.keys()):
            retval += "%s; %s: %s\n" % (" " * indent, k, self.metadata[k])
        return retval

    def clone_inverted(self, account, asserted=None, metadata={}):
        return Posting(account,
                       self.amount.clone_inverted(),
                       asserted=asserted,
                       unit_price=self.unit_price,
                       metadata=metadata)

class Amount(EasyEquality):
    def __init__(self, number, currency, reverse=False, unlimited=False):
        self.number = Decimal(number)
        self.reverse = reverse
        self.unlimited = unlimited
        self.currency = currency

    def format(self):
        # Commodities must be quoted in ledger if they have
        # whitespace or numerals.
        if re.search(r'[\s0-9]', self.currency):
            currency = "\"%s\"" % (self.currency)
        else:
            currency = self.currency
        if self.unlimited:
            number = str(abs(self.number))
        else:
            number = "%0.2f" % (abs(self.number))
        if self.number.is_signed() != self.reverse:
            prefix = "-"
        else:
            prefix = ""
        if len(currency) == 1:
            # $ comes before
            return "%s%s%s" % (prefix, currency, number)
        else:
            # USD comes after
            return "%s%s %s" % (prefix, number, currency)

    def clone_inverted(self):
        return Amount(self.number,
                      self.currency,
                      reverse=not(self.reverse),
                      unlimited=self.unlimited)

class Converter(object):
    @staticmethod
    def clean_id(id):
        return id.replace('/', '_').\
            replace('$', '_').\
            replace(' ', '_').\
            replace('@', '_').\
            replace('*', '_').\
            replace('+', '_').\
            replace('[', '_').\
            replace(']', '_')

    def __init__(
            self,
            ledger=None,
            unknownaccount=None,
            currency='$',
            indent=4,
            payee_format=None):
        self.lgr = ledger
        self.indent = indent
        self.unknownaccount = unknownaccount
        self.currency = currency.upper()
        self.payee_format = payee_format
        if self.currency == "USD":
            self.currency = "$"

    def mk_dynamic_account(self, payee, exclude):
        if self.lgr is None:
            return self.unknownaccount or 'Expenses:Misc'
        else:
            account = self.lgr.get_account_by_payee(payee, exclude)
            if account is None:
                return self.unknownaccount or 'Expenses:Misc'
            else:
                return account

class OfxConverter(Converter):
    def __init__(
            self,
            account,
            name,
            indent=4,
            ledger=None,
            fid=None,
            unknownaccount=None,
            payee_format=None,
            hardcodeaccount=None,
            shortenaccount=False,
            security_list=SecurityList(
            [])):
        super(OfxConverter, self).__init__(ledger=ledger,
                                           indent=indent,
                                           unknownaccount=unknownaccount,
                                           currency=account.statement.currency,
                                           payee_format=payee_format)
        self.real_acctid = account.account_id
        if hardcodeaccount is not None:
            self.acctid = hardcodeaccount
        elif shortenaccount:
            self.acctid = account.account_id[-4:]
        else:
            self.acctid = account.account_id
        self.payee_format = payee_format
        self.security_list = security_list

        if fid is not None:
            self.fid = fid
        else:
            if account.institution is None:
                raise EmptyInstitutionException(
                    "Institution provided by OFX is empty and no fid supplied!")
            else:
                self.fid = account.institution.fid
        self.name = name

    def mk_ofxid(self, txnid):
        if self.acctid != self.real_acctid:
            # Some banks insert the bank account number into the transaction ID
            # We will do this to properly hide the account number for privacy
            # reasons
            txnid = txnid.replace(self.real_acctid, self.acctid)
        return Converter.clean_id("%s.%s.%s" % (self.fid, self.acctid, txnid))

    def format_payee(self, txn):
        payee = ""
        if (hasattr(txn, 'payee') and txn.payee is not None):
            payee = txn.payee
        memo = ""
        if (hasattr(txn, 'memo') and txn.memo is not None):
            memo = txn.memo
        txntype = ""
        if (hasattr(txn, 'type') and txn.type is not None):
            txntype = txn.type
        tferaction = ""
        if (hasattr(txn, 'tferaction') and txn.tferaction is not None):
            tferaction = txn.tferaction.lower()

        if payee != "" and self.lgr is not None:
            payee = self.lgr.get_autosync_payee(payee, self.name)

        payee_format = self.payee_format

        if payee_format is None:
            # Default when not provided
            payee_format = "{payee} {memo}"

            # Alternate for when payee and memo are blank, sometimes true for
            # investment accounts.
            if (payee == "") and (memo == ""):
                payee_format = "{account}: {txntype}"
                if tferaction != "":
                    payee_format += ": {tferaction}"

            # Sometimes memo/payee are simply longer versions of the other.
            if memo.startswith(payee):
                payee = ""
            if payee.startswith(memo):
                memo = ""

        return payee_format.format(
            payee=payee,
            memo=memo,
            txntype=txntype,
            account=self.name,
            tferaction=tferaction).strip()

    def format_balance(self, statement):
        # Get date. Ensure the date is a date-like object.
        if (hasattr(statement, 'balance_date') and
                hasattr(statement.balance_date, 'strftime')):
            date = statement.balance_date
        elif (hasattr(statement, 'end_date') and
              hasattr(statement.end_date, 'strftime')):
            date = statement.end_date
        else:
            return ""
        if (hasattr(statement, 'balance')):
            return Transaction(
                date=date,
                cleared=True,
                payee="--Autosync Balance Assertion",
                postings=[
                    Posting(
                        self.name,
                        Amount(Decimal("0"), currency=self.currency),
                        asserted=Amount(statement.balance, self.currency))
                ]).format(self.indent)
        else:
            return ""

    def format_initial_balance(self, statement):
        if (hasattr(statement, 'balance')):
            initbal = statement.balance
            for txn in statement.transactions:
                initbal -= txn.amount

            posting = Posting(
                        self.name,
                        Amount(
                            initbal,
                            currency=self.currency),
                        metadata={
                            "ofxid": self.mk_ofxid(AUTOSYNC_INITIAL)})

            return Transaction(
                date=statement.start_date,
                payee="--Autosync Initial Balance",
                cleared=True,
                postings=[
                    posting,
                    posting.clone_inverted("Assets:Equity").format(self.indent)],
            ).format(
                self.indent)
        else:
            return ""

    # Return the ticker symbol of the security with CUSIP, if it exists in the
    # security_list mapping. Otherwise, simply return the CUSIP.
    def maybe_get_ticker(self, cusip):
        security = self.security_list.find_cusip(cusip)
        if security is not None:
            return security.ticker
        else:
            return cusip

    def convert(self, txn):
        """
        Convert an OFX Transaction to a posting
        """

        ofxid = self.mk_ofxid(txn.id)
        metadata = {}
        posting_metadata = {"ofxid": ofxid}

        if isinstance(txn, OfxTransaction):
            posting = Posting(self.name,
                              Amount(txn.amount, self.currency),
                              metadata=posting_metadata)
            return Transaction(
                date=txn.date,
                payee=self.format_payee(txn),
                postings=[
                    posting,
                    posting.clone_inverted(
                        self.mk_dynamic_account(self.format_payee(txn),
                                                exclude=self.name))])
        elif isinstance(txn, InvestmentTransaction):
            acct1 = self.name
            acct2 = self.name

            posting1 = None
            posting2 = None

            security = self.maybe_get_ticker(txn.security)

            if isinstance(txn.type, str):
                # recent versions of ofxparse
                if re.match('^(buy|sell)', txn.type):
                    acct2 = self.unknownaccount or 'Assets:Unknown'
                elif txn.type == 'transfer':
                    acct2 = 'Transfer'
                elif txn.type == 'reinvest':
                    # reinvestment of income
                    # TODO: make this configurable
                    acct2 = 'Income:Interest'
                elif txn.type == 'income' and txn.income_type == 'DIV':
                    # Fidelity lists non-reinvested dividend income as
                    # type: income, income_type: DIV
                    # TODO: determine how dividend income is listed from other institutions
                    # income/DIV transactions do not involve buying or selling a security
                    # so their postings need special handling compared to
                    # others
                    metadata['dividend_from'] = security
                    acct2 = 'Income:Dividends'
                    posting1 = Posting(acct1,
                                       Amount(txn.total, self.currency),
                                       metadata=posting_metadata)
                    posting2 = posting1.clone_inverted(acct2)
                else:
                    # ???
                    pass
            else:
                # Old version of ofxparse
                if (txn.type in [0, 1, 3, 4]):
                    # buymf, sellmf, buystock, sellstock
                    acct2 = self.unknownaccount or 'Assets:Unknown'
                elif (txn.type == 2):
                    # reinvest
                    acct2 = 'Income:Interest'
                else:
                    # ???
                    pass

            aux_date = None
            if txn.settleDate is not None and \
               txn.settleDate != txn.tradeDate:
                aux_date = txn.settleDate

            # income/DIV already defined above;
            # this block defines all other posting types
            if posting1 is None and posting2 is None:
                posting1 = Posting(
                    acct1,
                    Amount(
                        txn.units,
                        security,
                        unlimited=True),
                    unit_price=Amount(
                        txn.unit_price,
                        self.currency,
                        unlimited=True),
                    metadata=posting_metadata)
                posting2 = Posting(
                    acct2,
                    Amount(
                        txn.units *
                        txn.unit_price,
                        self.currency,
                        reverse=True))
            else:
                # Previously defined if type:income income_type/DIV
                pass

            return Transaction(
                date=txn.tradeDate,
                aux_date=aux_date,
                payee=self.format_payee(txn),
                metadata=metadata,
                postings=[posting1, posting2]
            )

    def format_position(self, pos):
        if hasattr(pos, 'date') and hasattr(pos, 'security') and \
           hasattr(pos, 'unit_price'):
            dateStr = pos.date.strftime("%Y/%m/%d %H:%M:%S")
            return "P %s %s %s\n" % (
                dateStr, self.maybe_get_ticker(pos.security), pos.unit_price)

class CsvConverter(Converter):
    @staticmethod
    def make_converter(fieldset, dialect, name=None, **kwargs):
        for klass in CsvConverter.descendants():
            if klass.FIELDSET <= fieldset:
                return klass(dialect, name=name, **kwargs)
        # Found no class, bail
        raise Exception('Cannot determine CSV type')

    @classmethod
    def descendants(cls):
        retval = cls.__subclasses__()
        for cls2 in cls.__subclasses__():
            retval.extend(cls2.descendants())
        return retval

    # By default, return an MD5 of the key-value pairs in the row.
    # If a better ID is available, should be overridden.
    def get_csv_id(self, row):
        h = hashlib.md5()
        for key in sorted(row.keys()):
            h.update(("%s=%s\n" % (key, row[key])).encode('utf-8'))
        return h.hexdigest()

    def __init__(
            self,
            dialect,
            name=None,
            indent=4,
            ledger=None,
            unknownaccount=None,
            payee_format=None):
        super(CsvConverter, self).__init__(
            indent=indent,
            unknownaccount=unknownaccount,
            payee_format=payee_format)
        self.name = name
        self.dialect = dialect

    def format_payee(self, row):
        return re.sub(r"\s+", " ",
                      self.payee_format.format(**row).strip())

class PaypalConverter(CsvConverter):
    FIELDSET = {
        'Currency',
        'Date',
        'Gross',
        'Item Title',
        'Name',
        'Net',
        'Status',
        'To Email Address',
        'Transaction ID',
        'Type'}

    def __init__(self, *args, **kwargs):
        super(PaypalConverter, self).__init__(*args, **kwargs)
        if self.payee_format is None:
            self.payee_format = \
                "{Name} {To Email Address} {Item Title} ID: {Transaction ID}, {Type}"

    def get_csv_id(self, row):
        return "paypal.%s" % (Converter.clean_id(row['Transaction ID']))

    def convert(self, row):
        if (((row['Status'] != "Completed") and (row['Status'] != "Refunded") and (
                row['Status'] != "Reversed")) or (row['Type'] == "Shopping Cart Item")):
            return ""
        else:
            currency = row['Currency']
            posting_metadata = {"csvid": self.get_csv_id(row)}
            net = Decimal(row['Net'].replace(',', ''))
            gross = Decimal(row['Gross'].replace(',', ''))

            if row['Type'] == "Add Funds from a Bank Account" or \
               row['Type'] == "Charge From Debit Card":
                posting = Posting(self.name,
                                  Amount(net, currency),
                                  metadata=posting_metadata)
                postings = [posting,
                            posting.clone_inverted("Transfer:Paypal")]
            else:
                posting = Posting(self.name,
                                  Amount(gross, currency),
                                  metadata=posting_metadata)
                postings = [
                    posting,
                    # TODO Our payees are breaking the payee search in
                    # mk_dynamic_account
                    # self.mk_dynamic_account(payee, exclude=self.name),
                    posting.clone_inverted("Expenses:Misc")]
            return Transaction(
                date=datetime.datetime.strptime(row['Date'], "%m/%d/%Y"),
                payee=self.format_payee(row),
                postings=postings)

# Apparently Paypal has another CSV

class PaypalAlternateConverter(CsvConverter):
    FIELDSET = {"Date", "Name", "Type", "Status", "Amount"}

    def __init__(self, *args, **kwargs):
        super(PaypalAlternateConverter, self).__init__(*args, **kwargs)
        if self.payee_format is None:
            self.payee_format = "{Name}: {Type}"

    def mk_amount(self, row, reverse=False):
        currency = '$'
        if 'Currency' in row:
            currency = row['Currency']
        return Amount(
            Decimal(
                re.sub(
                    r"\$",
                    "",
                    row['Amount'])),
            currency,
            reverse=reverse)

    def convert(self, row):
        if (((row['Status'] != "Completed") and (row['Status'] != "Refunded") and (
                row['Status'] != "Reversed")) or (row['Type'] == "Shopping Cart Item")):
            return ""
        else:
            posting_metadata = {"csvid": self.get_csv_id(row)}
            posting = Posting(self.name,
                              self.mk_amount(row),
                              metadata=posting_metadata)
            if row['Type'] == "Add Funds from a Bank Account" \
               or row['Type'] == "Charge From Debit Card":

                posting2_account="Transfer:Paypal"
            else:
                posting2_account="Expenses:Misc"
            return Transaction(
                date=datetime.datetime.strptime(row['Date'], "%m/%d/%Y"),
                payee=self.format_payee(row),
                postings=[posting,posting.clone_inverted(posting2_account)])

class AmazonConverter(CsvConverter):
    FIELDSET = {'Currency', 'Title', 'Order Date', 'Order ID'}

    def __init__(self, *args, **kwargs):
        super(AmazonConverter, self).__init__(*args, **kwargs)
        self.dialect.doublequote = True

    def mk_amount(self, row, reverse=False):
        currency = row['Currency']
        if currency == "USD":
            currency = "$"
        return Amount(
            Decimal(
                re.sub(
                    r"\$",
                    "",
                    row['Item Total'])),
            currency,
            reverse=reverse)

    def get_csv_id(self, row):
        return "amazon.%s" % (Converter.clean_id(row['Order ID']))

    def convert(self, row):
        posting = Posting(
                    self.name,
                    self.mk_amount(row),
                    metadata={
                        "url": "https://www.amazon.com/gp/css/summary/print.html/ref=od_aui_print_invoice?ie=UTF8&orderID=%s" %  # noqa E501
                        (row['Order ID']),
                        "csvid": self.get_csv_id(row)})

        return Transaction(
            date=datetime.datetime.strptime(
                row['Order Date'],
                "%m/%d/%y"),
            payee=row['Title'],
            postings=[posting,
                      posting.clone_inverted("Expenses:Misc")])

class MintConverter(CsvConverter):
    FIELDSET = {
        'Date',
        'Amount',
        'Description',
        'Account Name',
        'Category',
        'Transaction Type'}

    def __init__(self, *args, **kwargs):
        super(MintConverter, self).__init__(*args, **kwargs)

    def mk_amount(self, row, reverse=False):
        return Amount(Decimal(row['Amount']), '$', reverse=reverse)

    def convert(self, row):
        account = self.name
        if account is None:
            account = row['Account Name']
        postings = []
        posting_metadata = {"csvid": "mint.%s" % (self.get_csv_id(row))}
        if (row['Transaction Type'] == 'credit'):
            posting = Posting(account,
                              self.mk_amount(row, reverse=True),
                              metadata=posting_metadata)
            postings = [posting,
                        posting.clone_inverted(row['Category'])]
        else:
            posting = Posting(account,
                              self.mk_amount(row),
                              metadata=posting_metadata)
            postings = [posting,
                        posting.clone_inverted("Expenses:%s" % (row['Category']))]

        return Transaction(
            date=datetime.datetime.strptime(row['Date'], "%m/%d/%Y"),
            payee=row['Description'],
            postings=postings)

# Simple.com
class SimpleConverter(CsvConverter):
    FIELDSET = {
        "Date",
        "Recorded at",
        "Scheduled for",
        "Amount",
        "Activity",
        "Pending",
        "Raw description",
        "Description",
        "Category folder",
        "Category",
        "Street address",
        "City",
        "State",
        "Zip",
        "Latitude",
        "Longitude",
        "Memo"}

    def __init__(self, *args, **kwargs):
        super(SimpleConverter, self).__init__(*args, **kwargs)

    def convert(self, row):
        amount = abs(float(row['Amount']))
        reverse = row['Amount'][0] == '-'

        if reverse:
            account = "Expenses:%s" % (row['Category'])
        else:
            account = "Income:%s" % (row['Category'])

        posting_metadata = {
            "csvid": "simple.%s" % (self.get_csv_id(row)),
            "raw_description": row['Raw description'],
            "activity_type"  : row['Activity'],
        }
        if row['Memo']:
            posting_metadata["memo"] = row["Memo"]

        return Transaction(
           date = datetime.datetime.strptime(row['Date'], "%Y/%m/%d"),
           payee = row['Description'],
           postings = [Posting(self.name, Amount(amount, '$', reverse), metadata = posting_metadata),
                       Posting(account, Amount(amount, '$', reverse = not(reverse)))
                       ]
        )


#!/usr/bin/env python

# Copyright (c) 2013, 2014 Erik Hetzner
# Portions Copyright (c) 2016 James S Blachly, MD
#
# This file is part of ledger-autosync
#
# ledger-autosync is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# ledger-autosync is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ledger-autosync. If not, see
# <http://www.gnu.org/licenses/>.

from ofxclient.config import OfxConfig
import argparse
from ledgerautosync import LedgerAutosyncException
from ledgerautosync.converter import OfxConverter, AUTOSYNC_INITIAL, \
    ALL_AUTOSYNC_INITIAL, UNKNOWN_BANK_ACCOUNT
from ledgerautosync.converter import SecurityList
from ledgerautosync.sync import OfxSynchronizer, CsvSynchronizer
from ledgerautosync.ledgerwrap import mk_ledger, Ledger, HLedger, LedgerPython
import logging
import re
import sys
import traceback
import os
import os.path
import imp

def find_ledger_file(ledgerrcpath=None):
    """Returns main ledger file path or raise exception if it cannot be \
found."""
    if ledgerrcpath is None:
        ledgerrcpath = os.path.abspath(os.path.expanduser("~/.ledgerrc"))
    if "LEDGER_FILE" in os.environ:
        return os.path.abspath(os.path.expanduser(os.environ["LEDGER_FILE"]))
    elif os.path.exists(ledgerrcpath):
        # hacky
        ledgerrc = open(ledgerrcpath)
        for line in ledgerrc.readlines():
            md = re.match(r"--file\s+([^\s]+).*", line)
            if md is not None:
                return os.path.abspath(os.path.expanduser(md.group(1)))
    else:
        return None

def print_results(converter, ofx, ledger, txns, args):
    """
    This function is the final common pathway of program:

    Print initial balance if requested;
    Print transactions surviving de-duplication filter;
    Print balance assertions if requested;
    Print commodity prices obtained from position statements
    """

    if args.initial:
        if (not(ledger.check_transaction_by_id
                ("ofxid", converter.mk_ofxid(AUTOSYNC_INITIAL))) and
                not(ledger.check_transaction_by_id("ofxid", ALL_AUTOSYNC_INITIAL))):
            print(converter.format_initial_balance(ofx.account.statement))
    for txn in txns:
        print(converter.convert(txn).format(args.indent))
    if args.assertions:
        print(converter.format_balance(ofx.account.statement))

    # if OFX has positions use these to obtain commodity prices
    # and print "P" records to provide dated/timed valuations
    # Note that this outputs only the commodity price,
    # not your position (e.g. # shares), even though this is in the OFX record
    if hasattr(ofx.account.statement, 'positions'):
        for pos in ofx.account.statement.positions:
            print(converter.format_position(pos))

def make_ofx_converter(account,
                       name,
                       ledger,
                       indent,
                       fid,
                       unknownaccount,
                       payee_format,
                       hardcodeaccount,
                       shortenaccount,
                       security_list):
    klasses = OfxConverter.__subclasses__()
    if len(klasses) > 1:
        raise Exception("I found more than 1 OfxConverter subclass, but only "
                        "know how to handle 1. Remove extra subclasses from "
                        "the plugin directory")
    elif len(klasses) == 1:
        return klasses[0](account=account,
                          name=name,
                          ledger=ledger,
                          indent=indent,
                          fid=fid,
                          unknownaccount=unknownaccount,
                          payee_format=payee_format,
                          hardcodeaccount=hardcodeaccount,
                          shortenaccount=shortenaccount,
                          security_list=security_list)
    else:
        return OfxConverter(account=account,
                            name=name,
                            ledger=ledger,
                            indent=indent,
                            fid=fid,
                            unknownaccount=unknownaccount,
                            payee_format=payee_format,
                            hardcodeaccount=hardcodeaccount,
                            shortenaccount=shortenaccount,
                            security_list=security_list)

def sync(ledger, accounts, args):
    sync = OfxSynchronizer(ledger, shortenaccount=args.shortenaccount)
    for acct in accounts:
        try:
            (ofx, txns) = sync.get_new_txns(acct, resync=args.resync,
                                            max_days=args.max)
            if ofx is not None:
                converter = make_ofx_converter(account=ofx.account,
                                               name=acct.description,
                                               ledger=ledger,
                                               indent=args.indent,
                                               fid=None,
                                               unknownaccount=args.unknownaccount,
                                               payee_format=args.payee_format,
                                               hardcodeaccount=None,
                                               shortenaccount=args.shortenaccount,
                                               security_list=SecurityList(ofx))
                print_results(converter, ofx, ledger, txns, args)
        except KeyboardInterrupt:
            raise
        except BaseException:
            sys.stderr.write("Caught exception processing %s\n" %
                             (acct.description))
            traceback.print_exc(file=sys.stderr)

def import_ofx(ledger, args):
    sync = OfxSynchronizer(ledger, hardcodeaccount=args.hardcodeaccount,
                           shortenaccount=args.shortenaccount)
    ofx = OfxSynchronizer.parse_file(args.PATH)
    txns = sync.filter(
        ofx.account.statement.transactions,
        ofx.account.account_id)
    accountname = args.account
    if accountname is None:
        if ofx.account.institution is not None:
            accountname = "%s:%s" % (ofx.account.institution.organization,
                                     ofx.account.account_id)
        else:
            accountname = UNKNOWN_BANK_ACCOUNT

    # build SecurityList (including indexing by CUSIP and ticker symbol)
    security_list = SecurityList(ofx)

    converter = make_ofx_converter(account=ofx.account,
                                   name=accountname,
                                   ledger=ledger,
                                   indent=args.indent,
                                   fid=args.fid,
                                   unknownaccount=args.unknownaccount,
                                   payee_format=args.payee_format,
                                   hardcodeaccount=args.hardcodeaccount,
                                   shortenaccount=args.shortenaccount,
                                   security_list=security_list)
    print_results(converter, ofx, ledger, txns, args)

def import_csv(ledger, args):
    if args.account is None:
        raise Exception(
            "When importing a CSV file, you must specify an account name.")
    sync = CsvSynchronizer(ledger, payee_format=args.payee_format)
    txns = sync.parse_file(args.PATH, accountname=args.account,
                           unknownaccount=args.unknownaccount)
    if args.reverse:
        txns = reversed(txns)
    for txn in txns:
        print(txn.format(args.indent, args.assertions))

def load_plugins(config_dir):
    plugin_dir = os.path.join(config_dir, 'ledger-autosync', 'plugins')
    if os.path.isdir(plugin_dir):
        for plugin in filter(
                re.compile(
                    '.py$',
                    re.IGNORECASE).search,
                os.listdir(plugin_dir)):
            # Quiet loader
            import ledgerautosync.plugins  # noqa: F401
            path = os.path.join(plugin_dir, plugin)
            imp.load_source(
                'ledgerautosync.plugins.%s' %
                (os.path.splitext(plugin)[0]), path)

def run(args=None, config=None):
    if args is None:
        args = sys.argv[1:]

    parser = argparse.ArgumentParser(description='Synchronize ledger.')
    parser.add_argument('-m', '--max', type=int, default=90,
                        help='maximum number of days to process')
    parser.add_argument('-r', '--resync', action='store_true', default=False,
                        help='do not stop until max days reached')
    parser.add_argument('PATH', nargs='?', help='do not sync; import from OFX \
file')
    parser.add_argument('-a', '--account', type=str, default=None,
                        help='sync only the named account; \
if importing from file, set account name for import')
    parser.add_argument('-l', '--ledger', type=str, default=None,
                        help='specify ledger file to READ for syncing')
    parser.add_argument(
        '-L',
        '--no-ledger',
        dest='no_ledger',
        action='store_true',
        default=False,
        help='do not de-duplicate against a ledger file')
    parser.add_argument('-i', '--indent', type=int, default=4,
                        help='number of spaces to use for indentation')
    parser.add_argument('--initial', action='store_true', default=False,
                        help='create initial balance entries')
    parser.add_argument('--fid', type=int, default=None,
                        help='pass in fid value for OFX files that do not \
supply it')
    parser.add_argument(
        '--hardcode-account',
        type=str,
        default=None,
        dest='hardcodeaccount',
        help='pass in hardcoded account number for OFX files \
to maintain ledger files without real account numbers')
    parser.add_argument(
        '--shorten-account',
        default=False,
        action='store_true',
        dest='shortenaccount',
        help='shorten all account numbers to last 4 digits \
to maintain ledger files without full account numbers')
    parser.add_argument('--unknown-account', type=str, dest='unknownaccount',
                        default=None,
                        help='specify account name to use when one can\'t be \
found by payee')
    parser.add_argument('--assertions', action='store_true', default=False,
                        help='create balance assertion entries')
    parser.add_argument('-d', '--debug', action='store_true', default=False,
                        help='enable debug logging')
    parser.add_argument('--hledger', action='store_true', default=False,
                        help='force use of hledger (on by default if invoked \
                        as hledger-autosync)')
    parser.add_argument(
        '--payee-format',
        type=str,
        default=None,
        dest='payee_format',
        help="""Format string to use for generating the payee line. Substitutions
        can be written using {memo}, {payee}, {txntype}, {account} or
        {tferaction} for OFX. If the input file is a CSV file,
        substitutions are written using the CSV file column names
        between {}.""")
    parser.add_argument('--python', action='store_true', default=False,
                        help='use the ledger python interface')
    parser.add_argument('--slow', action='store_true', default=False,
                        help='use slow, but possibly more robust, method of \
calling ledger (no subprocess)')
    parser.add_argument('--which', action='store_true', default=False,
                        help='display which version of ledger (cli), hledger, \
or ledger (python) will be used by ledger-autosync to check for previous \
transactions')
    parser.add_argument('--reverse', action='store_true', default=False,
                        help='print CSV transactions in reverse order')
    parser.add_argument('-o', '--ofxconfig', type=str, default=None,
                        help='specify config file for ofxclient')
    args = parser.parse_args(args)
    if sys.argv[0][-16:] == "hledger-autosync":
        args.hledger = True

    ledger_file = None
    if args.ledger and args.no_ledger:
        raise LedgerAutosyncException(
            'You cannot specify a ledger file and -L')
    elif args.ledger:
        ledger_file = args.ledger
    else:
        ledger_file = find_ledger_file()
    if args.debug:
        logging.basicConfig(level=logging.DEBUG)

    if ledger_file is None:
        sys.stderr.write("LEDGER_FILE environment variable not set, and no \
.ledgerrc file found, and -l argument was not supplied: running with deduplication disabled. \
All transactions will be printed!\n")
        ledger = None
    elif args.no_ledger:
        ledger = None
    elif args.hledger:
        ledger = HLedger(ledger_file)
    elif args.python:
        ledger = LedgerPython(ledger_file=ledger_file)
    elif args.slow:
        ledger = Ledger(ledger_file=ledger_file, no_pipe=True)
    else:
        ledger = mk_ledger(ledger_file)

    if args.which:
        sys.stderr.write("ledger-autosync is using ")
        if isinstance(ledger, Ledger):
            sys.stderr.write("ledger (cli)\n")
        elif isinstance(ledger, HLedger):
            sys.stderr.write("hledger\n")
        elif isinstance(ledger, LedgerPython):
            sys.stderr.write("ledger.so (python)\n")
        exit()

    config_dir = os.environ.get('XDG_CONFIG_HOME',
                                os.path.join(os.path.expanduser("~"),
                                             '.config'))

    load_plugins(config_dir)

    if args.PATH is None:
        if config is None:
            if args.ofxconfig is None:
                config_file = os.path.join(config_dir, 'ofxclient.ini')
            else:
                config_file = args.ofxconfig
            if (os.path.exists(config_file)):
                config = OfxConfig(file_name=config_file)
            else:
                config = OfxConfig()
        accounts = config.accounts()
        if args.account:
            accounts = [acct for acct in accounts
                        if acct.description == args.account]
        sync(ledger, accounts, args)
    else:
        _, file_extension = os.path.splitext(args.PATH.lower())
        if file_extension == '.csv':
            import_csv(ledger, args)
        else:
            import_ofx(ledger, args)

if __name__ == '__main__':
    run()


# Copyright 2018 The pybadge Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Nox config for running lint and unit tests."""

import nox

def _run_tests(session):
    session.run(
        'py.test',
        '--quiet',
        'tests',
        *session.posargs
    )

@nox.session
def lint(session):
    """Run flake8.
    Returns a failure if flake8 finds linting errors or sufficiently
    serious code quality issues.
    """
    session.install('flake8')
    session.run('flake8',
                'pypadges,tests')

@nox.session
def unit(session):
    """Run the unit test suite."""
    session.install('-e', '.[dev]')
    _run_tests(session)

@nox.session(python=['3.4', '3.5', '3.6', '3.7'])
@nox.parametrize('install',
                 ['Jinja2==2.9.0', 'Pillow==5.0.0', 'requests==2.9.0'])
def compatibility(session, install):
    """Run the unit test suite with each support library and Python version."""

    session.install('-e', '.[dev]')
    session.install(install)
    _run_tests(session)

@nox.session(python=['3.6'])
def type_check(session):
    """Run type checking using pytype."""
    session.install('-e', '.[dev]')
    session.install('pytype')
    session.run(
        'pytype',
        '--python-version=3.6',
        '--disable=pyi-error',
        'pybadges')


# Copyright 2018 The pybadge Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Measure the width, in pixels, of a string rendered using DejaVu Sans 110pt.

Uses a PIL/Pillow to determine the string length.
"""

from PIL import ImageFont

from pybadges import text_measurer

class PilMeasurer(text_measurer.TextMeasurer):
    """Measures the width of a string using PIL/Pillow."""

    def __init__(self, deja_vu_sans_path: str):
        """Initializer for PilMeasurer.

        Args:
            deja_vu_sans_path: The path to the DejaVu Sans TrueType (.ttf) font
                file.
        """
        self._font = ImageFont.truetype(deja_vu_sans_path, 110)

    def text_width(self, text: str) -> float:
        """Returns the width, in pixels, of a string in DejaVu Sans 110pt."""
        width, _ = self._font.getsize(text)
        return width


# Copyright 2018 The pybadge Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""A setup module for pybadges."""

import base64
import re

from setuptools import setup

def get_long_description():
    """Transform README.md into a usable long description.

    Replaces relative references to svg images to absolute https references.
    """

    with open('README.md') as f:
        read_me = f.read()

    def replace_relative_with_absolute(match):
        svg_path = match.group(0)[1:-1]
        return ('(https://github.com/google/pybadges/raw/master/'
                '%s?sanitize=true)' % svg_path)

    return re.sub(r'\(tests/golden-images/.*?\.svg\)',
                  replace_relative_with_absolute,
                  read_me)

setup(
    name='pybadges',
    version='2.0.1',
    author='Brian Quinlan',
    author_email='brian@sweetapp.com',
    classifiers=[
        'Development Status :: 5 - Production/Stable',
        'Intended Audience :: Developers',
        'Intended Audience :: Developers',
        'License :: OSI Approved :: Apache Software License',
        'Programming Language :: Python',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.4',
        'Programming Language :: Python :: 3.5',
        'Programming Language :: Python :: 3.6',
        'Programming Language :: Python :: 3.7',
        'Operating System :: OS Independent',
    ],
    description='A library and command-line tool for generating Github-style ' +
                'badges',
    keywords="github gh-badges badge shield status",
    package_data={'pybadges': ['badge-template-full.svg',
                               'default-widths.json']},
    long_description=get_long_description(),
    long_description_content_type='text/markdown',
    python_requires='>=3.4',
    install_requires=['Jinja2>=2.9.0,<3', 'requests>=2.9.0,<3'],
    extras_require={
        'pil-measurement': ['Pillow>=5,<6'],
        'dev': ['fonttools>=3.26', 'nox', 'Pillow>=5',
                'pytest>=3.6'],
    },
    license='Apache-2.0',
    packages=["pybadges"],
    url='https://github.com/google/pybadges')


# Copyright 2018 The pybadge Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Measure the width, in pixels, of a string rendered using DejaVu Sans 110pt.

Uses a precalculated set of metrics to calculate the string length.
"""

import io
import json
import pkg_resources
from typing import cast, Mapping, TextIO, Type

from pybadges import text_measurer

class PrecalculatedTextMeasurer(text_measurer.TextMeasurer):
    """Measures the width of a string using a precalculated set of tables."""

    _default_cache = None

    def __init__(self, default_character_width: float,
                 char_to_width: Mapping[str, float],
                 pair_to_kern: Mapping[str, float]):
        """Initializer for PrecalculatedTextMeasurer.

        Args:
            default_character_width: the average width, in pixels, of a
                character in DejaVu Sans 110pt.
            char_to_width: a mapping between a character and it's width,
                in pixels, in DejaVu Sans 110pt.
            pair_to_kern: a mapping between pairs of characters and the kerning
                distance between them e.g. text_width("IJ") =>
                    (char_to_width["I"] + char_to_width["J"]
                    - pair_to_kern.get("IJ", 0))
        """
        self._default_character_width = default_character_width
        self._char_to_width = char_to_width
        self._pair_to_kern = pair_to_kern

    def text_width(self, text: str) -> float:
        """Returns the width, in pixels, of a string in DejaVu Sans 110pt."""
        width = 0
        for index, c in enumerate(text):
            width += self._char_to_width.get(c, self._default_character_width)
            width -= self._pair_to_kern.get(text[index:index + 2], 0)

        return width

    @staticmethod
    def from_json(f: TextIO) -> 'PrecalculatedTextMeasurer':
        """Return a PrecalculatedTextMeasurer given a JSON stream.

        See precalculate_text.py for details on the required format.
        """
        o = json.load(f)
        return PrecalculatedTextMeasurer(o['mean-character-length'],
                                         o['character-lengths'],
                                         o['kerning-pairs'])

    @classmethod
    def default(cls) -> 'PrecalculatedTextMeasurer':
        """Returns a reasonable default PrecalculatedTextMeasurer."""
        if cls._default_cache is not None:
            return cls._default_cache

        if pkg_resources.resource_exists(__name__, 'default-widths.json.xz'):
            import lzma
            with pkg_resources.resource_stream(__name__,
                                               'default-widths.json.xz') as f:
                with lzma.open(f, "rt") as g:
                    cls._default_cache = PrecalculatedTextMeasurer.from_json(
                        cast(TextIO, g))
                    return cls._default_cache
        elif pkg_resources.resource_exists(__name__, 'default-widths.json'):
            with pkg_resources.resource_stream(__name__,
                                               'default-widths.json') as f:
                cls._default_cache = PrecalculatedTextMeasurer.from_json(
                    io.TextIOWrapper(f, encoding='utf-8'))
                return cls._default_cache
        else:
            raise ValueError('could not load default-widths.json')


# Copyright 2018 The pybadge Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Creates a github-style badge as a SVG image.

This package seeks to generate semantically-identical output to the JavaScript
gh-badges library
(https://github.com/badges/shields/blob/master/doc/gh-badges.md)

>>> badge(left_text='coverage', right_text='23%', right_color='red')
'<svg...</svg>'
>>> badge(left_text='build', right_text='green', right_color='green',
...       whole_link="http://www.example.com/")
'<svg...</svg>'
>>> # base64-encoded PNG image
>>> image_data = 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVQI12P4zwAD/xkYAA/+Af8iHnLUAAAAAElFTkSuQmCC'
>>> badge(left_text='build', right_text='green', right_color='green',
...       logo="data:image/png;base64," + image_data)
'<svg...</svg>'
"""

import base64
import imghdr
import mimetypes
from typing import Optional
import urllib.parse
from xml.dom import minidom

import jinja2
import requests

from pybadges import text_measurer
from pybadges import precalculated_text_measurer

_JINJA2_ENVIRONMENT = jinja2.Environment(
    trim_blocks=True,
    lstrip_blocks=True,
    loader=jinja2.PackageLoader('pybadges', '.'),
    autoescape=jinja2.select_autoescape(['svg']))

# Use the same color scheme as describe in:
# https://github.com/badges/shields/blob/master/lib/colorscheme.json

_NAME_TO_COLOR = {
    'brightgreen': '#4c1',
    'green': '#97CA00',
    'yellow': '#dfb317',
    'yellowgreen': '#a4a61d',
    'orange': '#fe7d37',
    'red': '#e05d44',
    'blue': '#007ec6',
    'grey': '#555',
    'gray': '#555',
    'lightgrey': '#9f9f9f',
    'lightgray': '#9f9f9f',
}

def _remove_blanks(node):
    for x in node.childNodes:
        if x.nodeType == minidom.Node.TEXT_NODE:
            if x.nodeValue:
                x.nodeValue = x.nodeValue.strip()
        elif x.nodeType == minidom.Node.ELEMENT_NODE:
            _remove_blanks(x)

def _embed_image(url: str) -> str:
    parsed_url = urllib.parse.urlparse(url)

    if parsed_url.scheme == 'data':
        return url
    elif parsed_url.scheme.startswith('http'):
        r = requests.get(url)
        r.raise_for_status()
        content_type = r.headers.get('content-type')
        if content_type is None:
            raise ValueError('no "Content-Type" header')
        content_type, image_type = content_type.split('/')
        if content_type != 'image':
            raise ValueError('expected an image, got "{0}"'.format(
                content_type))
        image_data = r.content
    elif parsed_url.scheme:
        raise ValueError('unsupported scheme "{0}"'.format(parsed_url.scheme))
    else:
        with open(url, 'rb') as f:
            image_data = f.read()
        image_type = imghdr.what(None, image_data)
        if not image_type:
            mime_type, _ = mimetypes.guess_type(url, strict=False)
            if not mime_type:
                raise ValueError('not able to determine file type')
            else:
                content_type, image_type = mime_type.split('/')
                if content_type != 'image':
                    raise ValueError('expected an image, got "{0}"'.format(
                        content_type or 'unknown'))

    encoded_image = base64.b64encode(image_data).decode('ascii')
    return 'data:image/{};base64,{}'.format(image_type, encoded_image)

def badge(left_text: str, right_text: str, left_link: Optional[str] = None,
          right_link: Optional[str] = None,
          whole_link: Optional[str] = None, logo: Optional[str] = None,
          left_color: str = '#555', right_color: str = '#007ec6',
          measurer: Optional[text_measurer.TextMeasurer] = None,
          embed_logo: bool = False) -> str:
    """Creates a github-style badge as an SVG image.

    >>> badge(left_text='coverage', right_text='23%', right_color='red')
    '<svg...</svg>'
    >>> badge(left_text='build', right_text='green', right_color='green',
    ...       whole_link="http://www.example.com/")
    '<svg...</svg>'

    Args:
        left_text: The text that should appear on the left-hand-side of the
            badge e.g. "coverage".
        right_text: The text that should appear on the right-hand-side of the
            badge e.g. "23%".
        left_link: The URL that should be redirected to when the left-hand text
            is selected.
        right_link: The URL that should be redirected to when the right-hand
            text is selected.
        whole_link: The link that should be redirected to when the badge is
            selected. If set then left_link and right_right may not be set.
        logo: A url representing a logo that will be displayed inside the
            badge. Can be a data URL e.g. "data:image/svg+xml;utf8,<svg..."
        left_color: The color of the part of the badge containing the left-hand
            text. Can be an valid CSS color
            (see https://developer.mozilla.org/en-US/docs/Web/CSS/color) or a
            color name defined here:
            https://github.com/badges/shields/blob/master/lib/colorscheme.json
        right_color: The color of the part of the badge containing the
            right-hand text. Can be an valid CSS color
            (see https://developer.mozilla.org/en-US/docs/Web/CSS/color) or a
            color name defined here:
            https://github.com/badges/shields/blob/master/lib/colorscheme.json
        measurer: A text_measurer.TextMeasurer that can be used to measure the
            width of left_text and right_text.
        embed_logo: If True then embed the logo image directly in the badge.
            This can prevent an HTTP request and some browsers will not render
            external image referenced. When True, `logo` must be a HTTP/HTTPS
            URI or a filesystem path. Also, the `badge` call may raise an
            exception if the logo cannot be loaded, is not an image, etc.
    """
    if measurer is None:
        measurer = (
            precalculated_text_measurer.PrecalculatedTextMeasurer
                .default())

    if (left_link or right_link) and whole_link:
        raise ValueError(
            'whole_link may not bet set with left_link or right_link')
    template = _JINJA2_ENVIRONMENT.get_template('badge-template-full.svg')

    if logo and embed_logo:
        logo = _embed_image(logo)

    svg = template.render(
        left_text=left_text,
        right_text=right_text,
        left_text_width=measurer.text_width(left_text) / 10.0,
        right_text_width=measurer.text_width(right_text) / 10.0,
        left_link=left_link,
        right_link=right_link,
        whole_link=whole_link,
        logo=logo,
        left_color=_NAME_TO_COLOR.get(left_color, left_color),
        right_color=_NAME_TO_COLOR.get(right_color, right_color),
    )
    xml = minidom.parseString(svg)
    _remove_blanks(xml)
    xml.normalize()
    return xml.documentElement.toxml()


# Copyright 2018 The pybadge Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Creates a JSON file that can be used by precalculated_text_measurer.py.

Creates a JSON file that can be used by
precalculated_test_measurer.PrecalculatedTextMeasurer to calculate the pixel
length of text strings rendered in DejaVu Sans font.

The output JSON object is formatted like:
{
    'mean-character-length': <float: the average pixel length of a character in
                              DejaVu Sans at 110pt>,
    'character-lengths': <Map[str, float]: a mapping between single characters
                          and their length in DejaVu Sans at 110pt>,
    'kerning-characters': <str: a string containing all of the characters that
                           kerning information is available for>,
    'kerning-pairs': <Map[str, float]: a mapping between pairs of characters
                      (e.g. "IJ") to the amount of kerning distance between
                      them. Positive values are *subtracted* from the string
                      length e.g. text-length-of("IJ") =>
                            (character-lengths["I"] + character-lengths["J"]
                            - kerning-pairs["IJ"])
                      If two characters both appear in 'kerning-characters' but
                      don't have an entry in 'kerning-pairs' then the kerning
                      distance between them is zero.
}

For information about the commands, run:
$ python3 - m pybadges.precalculate_text --help
"""

import argparse
import itertools
import json
import os.path
import statistics
from typing import Iterable, Mapping, TextIO

from fontTools import ttLib

from pybadges import pil_text_measurer
from pybadges import text_measurer

def generate_supported_characters(deja_vu_sans_path: str) -> Iterable[str]:
    """Generate the characters support by the font at the given path."""
    font = ttLib.TTFont(deja_vu_sans_path)
    for cmap in font['cmap'].tables:
        if cmap.isUnicode():
            for code in cmap.cmap:
                yield chr(code)

def generate_encodeable_characters(characters: Iterable[str],
                                   encodings: Iterable[str]) -> Iterable[str]:
    """Generates the subset of 'characters' that can be encoded by 'encodings'.

    Args:
        characters: The characters to check for encodeability e.g. 'abcd'.
        encodings: The encodings to check against e.g. ['cp1252', 'iso-8859-5'].

    Returns:
        The subset of 'characters' that can be encoded using one of the provided
        encodings.
    """
    for c in characters:
        for encoding in encodings:
            try:
                c.encode(encoding)
                yield c
            except UnicodeEncodeError:
                pass

def calculate_character_to_length_mapping(
        measurer: text_measurer.TextMeasurer,
        characters: Iterable[str]) -> Mapping[str, float]:
    """Return a mapping between each given character and its length.

    Args:
        measurer: The TextMeasurer used to measure the width of the text in
            pixels.
        characters: The characters to measure e.g. "ml".

    Returns:
        A mapping from the given characters to their length in pixels, as
        determined by 'measurer' e.g. {'m': 5.2, 'l', 1.2}.
    """
    char_to_length = {}

    for c in characters:
        char_to_length[c] = measurer.text_width(c)
    return char_to_length

def calculate_pair_to_kern_mapping(
        measurer: text_measurer.TextMeasurer,
        char_to_length: Mapping[str, float],
        characters: Iterable[str]) -> Mapping[str, float]:
    """Returns a mapping between each *pair* of characters and their kerning.

    Args:
        measurer: The TextMeasurer used to measure the width of each pair of
            characters.
        char_to_length: A mapping between characters and their length in pixels.
            Must contain every character in 'characters' e.g.
            {'h': 5.2, 'e': 4.0, 'l', 1.2, 'o': 5.0}.
        characters: The characters to generate the kerning mapping for e.g.
            'hel'.

    Returns:
        A mapping between each pair of given characters
        (e.g. 'hh', he', hl', 'eh', 'ee', 'el', 'lh, 'le', 'll') and the kerning
        adjustment for that pair of characters i.e. the difference between the
        length of the two characters calculated using 'char_to_length' vs.
        the length calculated by `measurer`. Positive values indicate that the
        length is less than using the sum of 'char_to_length'. Zero values are
        excluded from the map e.g. {'hl': 3.1, 'ee': -0.5}.
    """
    pair_to_kerning = {}
    for a, b in itertools.permutations(characters, 2):
        kerned_width = measurer.text_width(a + b)
        unkerned_width = char_to_length[a] + char_to_length[b]
        kerning = unkerned_width - kerned_width
        if abs(kerning) > 0.05:
            pair_to_kerning[a + b] = round(kerning, 3)
    return pair_to_kerning

def write_json(f: TextIO, deja_vu_sans_path: str,
               measurer: text_measurer.TextMeasurer,
               encodings: Iterable[str]) -> None:
    """Write the data required by PrecalculatedTextMeasurer to a stream."""
    supported_characters = list(
        generate_supported_characters(deja_vu_sans_path))
    kerning_characters = ''.join(
        generate_encodeable_characters(supported_characters, encodings))
    char_to_length = calculate_character_to_length_mapping(measurer,
                                                           supported_characters)
    pair_to_kerning = calculate_pair_to_kern_mapping(measurer, char_to_length,
                                                     kerning_characters)
    json.dump(
        {'mean-character-length': statistics.mean(char_to_length.values()),
         'character-lengths': char_to_length,
         'kerning-characters': kerning_characters,
         'kerning-pairs': pair_to_kerning},
        f, sort_keys=True, indent=1)

def main():
    parser = argparse.ArgumentParser(
        description='generate a github-style badge given some text and colors')

    parser.add_argument(
        '--deja-vu-sans-path',
        required=True,
        help='the path to the ttf font file containing DejaVu Sans. If not ' +
             'present on your system, you can download it from ' +
             'https://www.fontsquirrel.com/fonts/dejavu-sans')

    parser.add_argument(
        '--kerning-pair-encodings',
        action='append',
        default=['cp1252'],
        help='only include kerning pairs for the given encodings')

    parser.add_argument(
        '--output-json-file',
        default=os.path.join(os.path.dirname(__file__),
                             'default-widths.json'),
        help='the path where the generated JSON will be placed. If the ' +
             'provided filename extension ends with .xz then the output' +
             'will be compressed using lzma.')

    args = parser.parse_args()

    measurer = pil_text_measurer.PilMeasurer(args.deja_vu_sans_path)

    def create_file():
        if args.output_json_file.endswith('.xz'):
            import lzma
            return lzma.open(args.output_json_file, 'wt')
        else:
            return open(args.output_json_file, 'wt')

    with create_file() as f:
        write_json(
            f, args.deja_vu_sans_path, measurer, args.kerning_pair_encodings)

if __name__ == '__main__':
    main()


"""Common utility functions."""

from __future__ import absolute_import

import numpy
from numpy.ma.core import exp
import scipy.ndimage

from ssim.compat import ImageOps

def convolve_gaussian_2d(image, gaussian_kernel_1d):
    """Convolve 2d gaussian."""
    result = scipy.ndimage.filters.correlate1d(
        image, gaussian_kernel_1d, axis=0)
    result = scipy.ndimage.filters.correlate1d(
        result, gaussian_kernel_1d, axis=1)
    return result

def get_gaussian_kernel(gaussian_kernel_width=11, gaussian_kernel_sigma=1.5):
    """Generate a gaussian kernel."""
    # 1D Gaussian kernel definition
    gaussian_kernel_1d = numpy.ndarray((gaussian_kernel_width))
    norm_mu = int(gaussian_kernel_width / 2)

    # Fill Gaussian kernel
    for i in range(gaussian_kernel_width):
        gaussian_kernel_1d[i] = (exp(-(((i - norm_mu) ** 2)) /
                                     (2 * (gaussian_kernel_sigma ** 2))))
    return gaussian_kernel_1d / numpy.sum(gaussian_kernel_1d)

def to_grayscale(img):
    """Convert PIL image to numpy grayscale array and numpy alpha array.

    Args:
      img (PIL.Image): PIL Image object.

    Returns:
      (gray, alpha): both numpy arrays.
    """
    gray = numpy.asarray(ImageOps.grayscale(img)).astype(numpy.float)

    imbands = img.getbands()
    alpha = None
    if 'A' in imbands:
        alpha = numpy.asarray(img.split()[-1]).astype(numpy.float)

    return gray, alpha