#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#**************************************************************************
#*   Copyright (C) 2017 by Bernhard Scheirle bernhard@scheirle.de
#*   This program 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.
#*
#*   This program 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 this program.  If not, see <http://www.gnu.org/licenses/>
#**************************************************************************

"""
Skrooge AqBanking Bridge (SABB)
-------------------------------

Authors:
 * Bernhard Scheirle <bernhard@scheirle.de>

Changelog:

2.0.0 - 2019.05.09
    * Added auto repair for certain banks (Sprada, Netbank, Comdirect).
    * Added --disable-auto-repair command line option
    * Added --prefer-valutadate command line option
    * Removed --balance command line option

1.2.0 - 2019.04.28
    * Allow processing of accounts without an IBAN
      (e.g credit card accounts).
      In this case a fake IBAN is used:
      XX00<bank_number><account_number>

1.1.0 - 2018.05.21
    * Added command line parameter --terminal-emulator

1.0.0 - 2017.07.29
    * Initial release

"""

import argparse
import contextlib
import csv
import datetime
import io
import os
import re
import shutil
import subprocess
import sys
import tempfile
from distutils.version import LooseVersion

__VERSION__ = "2.0.0"


class Account(object):
    def __init__(self):
        self.bank_number    = ""
        self.account_number = ""
        self.iban           = ""
        self.fake_iban      = False

    def toString(self):
        return 'Account({}, {}, {})'.format(self.bank_number,
                                            self.account_number,
                                            self.iban)

    def isValid(self):
        return self.bank_number     != "" \
            and self.account_number != "" \
            and self.iban           != ""

    @staticmethod
    def _parse_single(file_content, regex):
        match = regex.search(file_content)
        if match:
            return match.group(1)
        return ""

    def parse(self, file_content):
        regex_account_number = re.compile('accountNumber=\"(.*)\"')
        regex_bank_number    = re.compile('bankCode=\"(.*)\"')
        regex_iban           = re.compile('iban=\"(.*)\"')

        self.account_number = self._parse_single(file_content, regex_account_number)
        self.bank_number    = self._parse_single(file_content, regex_bank_number)
        self.iban           = self._parse_single(file_content, regex_iban)

        if self.iban == "":
            self.fake_iban = True
            self.iban = "XX00{}{}".format(self.bank_number, self.account_number)

    def matches(self, bank_number, account_number):
        return self.bank_number == bank_number \
            and self.account_number == account_number


class Accounts(object):
    def __init__(self):
        self._account_map = {}

    def _account_map_key(self, bank_number, account_number):
        return str(bank_number) + '.' + str(account_number)

    def _update_account_map(self, account):
        if not account.isValid():
            return

        overwrite = True
        key = self._account_map_key(account.bank_number, account.account_number)
        if key in self._account_map:
            # Only overwrite the IBAN if the currently stored one is fake:
            overwrite = self._account_map[key].fake_iban

        if overwrite:
            self._account_map[key] = account

    def _build_iban_map(self):
        self._account_map = {}

        accounts_folder = os.path.expanduser('~/.aqbanking/settings/accounts/')
        for file_name in os.listdir(accounts_folder):
            if not file_name.endswith('.conf'):
                continue
            file_path = os.path.join(accounts_folder, file_name)
            with open(file_path, 'r') as account_file:
                content = account_file.read()
                account = Account()
                account.parse(content)
                self._update_account_map(account)

    def get_account(self, bank_number, account_number):
        if not self._account_map:
            self._build_iban_map()

        key = self._account_map_key(bank_number, account_number)
        if key in self._account_map:
            return self._account_map[key]
        else:
            return Account()

    def get_accounts(self):
        if not self._account_map:
            self._build_iban_map()
        return self._account_map.values()


class AqDialect(csv.Dialect):
    delimiter = ';'
    quotechar = '"'
    quoting = csv.QUOTE_NONE
    lineterminator = '\n'


class AqDialectTransfairs(csv.Dialect):
    delimiter = ';'
    quotechar = '"'
    quoting = csv.QUOTE_ALL
    lineterminator = '\n'


class SkroogeDialect(csv.Dialect):
    delimiter = ';'
    quotechar = '"'
    escapechar = '\\'
    quoting = csv.QUOTE_ALL
    lineterminator = '\n'


@contextlib.contextmanager
def TemporaryContextFile():
    with tempfile.TemporaryDirectory(prefix="SSAB.") as dir_path:
        context_file_path = os.path.join(dir_path, "context")
        open(context_file_path, 'a').close()
        yield context_file_path


class RepairMan(object):
    def repair_row(self, row):
        pass


class RepairManSpardaNetBank(RepairMan):
    """
    Sparda / Netbank only:
    If the payees name exceeds 27 characters the overflowing characters
    of the name gets stored at the beginning of the purpose field.

    This is the case, when one of the strings listed in Keywords is part
    of the purpose fields but does not start at the beginning.
    In this case, the part leading up to the keyword is to be treated as the
    tail of the payee.
    """

    Keywords = ['SEPA-BASISLASTSCHRIFT',
                'SEPA-ÜBERWEISUNG',
                'SEPA LOHN/GEHALT']

    def repair_row(self, row):
        comment = row['comment']

        for key in self.Keywords:
            offset = comment.find(key)
            if offset >= 0:
                if offset > 0:
                    row['payee'] = row['payee'] + comment[:offset]
                keyEnd = offset + len(key)
                row['mode'] = comment[offset:keyEnd]
                row['comment'] = comment[keyEnd:]
                break
        return row


class RepairManComdirect(RepairMan):
    Keywords = ['WERTPAPIERE',
                'LASTSCHRIFT / BELASTUNG',
                'ÜBERTRAG / ÜBERWEISUNG',
                'KONTOÜBERTRAG',
                'KUPON',
                'SUMME MONATSABRECHNUNG VISA',
                'KONTOABSCHLUSSABSCHLUSS ZINSEN']

    def repair_row(self, row):
        comment = row['comment'].strip()

        for key in self.Keywords:
            if comment.startswith(key):
                row['comment'] = comment[len(key):]
                row['mode'] = key
                break
        return row


class RepairManStrip(RepairMan):
    Keywords = ['date', 'mode', 'comment', 'payee', 'amount', 'unit']

    def repair_row(self, row):
        for key in self.Keywords:
            row[key] = row[key].strip()
        return row


class SABB(object):
    # Tools
    AqBanking = 'aqbanking-cli'

    ReturnValue_NormalExit = 0
    ReturnValue_InvalidVersion = 1

    def __init__(self):
        self.accounts = Accounts()
        self.repair_mans = [RepairManSpardaNetBank(),
                            RepairManComdirect(),
                            RepairManStrip()]

        self.output_folder       = None
        self.balance             = None
        self.terminal_emulator   = None
        self.prefer_valutadate   = None
        self.disable_auto_repair = None

    def build_command(self, executable, args):
        com = [executable]
        if executable is self.AqBanking:
            com.append('--charset=utf-8')
        com.extend(args)
        return com

    def get_csv_reader(self, process_result, fieldnames, dialect=AqDialect):
        input = process_result.stdout.decode("utf-8").replace('\t', ';')
        reader = csv.DictReader(input.splitlines(), fieldnames=fieldnames, dialect=dialect)
        return reader

    def get_csv_writer(self, fieldnames, generateHeader=True):
        output = io.StringIO("")
        writer = csv.DictWriter(output, fieldnames=fieldnames, dialect=SkroogeDialect)
        if generateHeader:
            writer.writeheader()
        return output, writer

    def format_iban(self, iban):
        result = ""
        for i, c in enumerate(iban):
            if i % 4 == 0:
                result = result + " "
            result = result + c
        return result.strip()

    def get_iban(self, bank_number, account_number):
        account = self.accounts.get_account(bank_number, account_number)
        if account.isValid():
            return self.format_iban(account.iban.upper())
        else:
            return ""

    def check_version(self):
        process_result = subprocess.run(
            self.build_command(self.AqBanking, ['versions']),
            stdout=subprocess.PIPE
        )
        process_result.check_returncode()
        lines = process_result.stdout.decode("utf-8").splitlines()
        valid_version = False
        for line in lines:
            line = line.strip()
            if line.startswith("AqBanking-CLI:"):
                line = line[14:].strip()
                if LooseVersion(line) >= LooseVersion("5.6.10"):
                    valid_version = True
                    break
        if not valid_version:
            print("Please install a newer version of aqbanking. At least version 5.6.10 is requiered.")
        return valid_version

    def get_accounts(self):
        if not self.check_version():
            return self.ReturnValue_InvalidVersion
        process_result = subprocess.run(
            self.build_command(self.AqBanking, ['listaccs']),
            stdout=subprocess.PIPE
        )
        process_result.check_returncode()
        fieldnames_input  = ['ignore','bank_number','account_number','bank_name','account_name']
        fieldnames_output = ['id']
        reader = self.get_csv_reader(process_result, fieldnames_input)
        output, writer = self.get_csv_writer(fieldnames_output)
        accounts = []
        for record in reader:
            row = {}
            account_id = self.get_iban(record['bank_number'], record['account_number'])
            if account_id == "" or account_id in accounts:
                # Filter out duplicates and empty ids
                continue
            row['id'] = account_id
            accounts.append(account_id)
            writer.writerow(row)
        print(output.getvalue().strip())
        return self.ReturnValue_NormalExit

    def convert_transactions(self, aqbanking_output, generateHeader):
        reader = csv.DictReader(aqbanking_output.splitlines(), dialect=AqDialectTransfairs)
        fieldnames_output = ['date', 'mode', 'comment', 'payee', 'amount', 'unit']
        output, writer = self.get_csv_writer(fieldnames_output, generateHeader)
        for record in sorted(reader, key=lambda row: row['date']):
            row = {}
            if self.prefer_valutadate:
                row['date'] = record['valutadate']
            else:
                row['date'] = record['date']
            row['mode'] = ""
            comment = record['purpose']
            for i in range(1, 12):
                comment = comment + record['purpose' + str(i)]
            row['comment'] = comment
            row['payee']   = record['remoteName'] + record['remoteName1']
            row['amount']  = record['value_value']
            row['unit']    = record['value_currency']
            if not self.disable_auto_repair:
                for repair_man in self.repair_mans:
                    row = repair_man.repair_row(row)
            writer.writerow(row)
        return output.getvalue()

    def process_context_file(self, context_file_path):
        self.output_folder = os.path.abspath(self.output_folder)
        if not os.path.exists(self.output_folder):
            os.makedirs(self.output_folder)

        files = {}
        for account in self.accounts.get_accounts():
            process_result = subprocess.run(
                self.build_command(self.AqBanking,
                                   ['listtrans',
                                    '--bank=' + account.bank_number,
                                    '--account=' + account.account_number,
                                    '-c',
                                    context_file_path
                                    ]),
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL
            )
            transactions = process_result.stdout.decode("utf-8")
            output_file_path = os.path.join(self.output_folder, self.format_iban(account.iban.upper()) + ".csv")
            if output_file_path in files:
                files[output_file_path] = files[output_file_path] + '\n' + self.convert_transactions(transactions, False)
            else:
                files[output_file_path] = self.convert_transactions(transactions, True)

        for path, content in files.items():
            with open(path, 'w') as f:
                f.write(content)

    def download(self):
        if not self.check_version():
            return self.ReturnValue_InvalidVersion
        with TemporaryContextFile() as context_file_path:
            args = ['request',
                    '--ignoreUnsupported',
                    '--transactions',
                    '-c',
                    context_file_path
                    ]

            command = str.split(self.terminal_emulator)
            command.extend(self.build_command(self.AqBanking, args))
            subprocess.run(command)

            self.process_context_file(context_file_path)

        return self.ReturnValue_NormalExit


def main():
    parser = argparse.ArgumentParser(prog='sabb', description='Skrooge AqBanking Bridge (SABB)')

    # Global arguments
    parser.add_argument('--version', '-v', action='version', version='%(prog)s ' + __VERSION__, help='Shows version information.')
    subparsers = parser.add_subparsers(title='Commands', dest='command')

    # Command: listaccounts
    parser_listaccounts = subparsers.add_parser('listaccounts', help='Returns a list of accounts that can be queried with AqBanking.')

    # Command: bulkdownload
    parser_download = subparsers.add_parser('bulkdownload',         help='Downloads all transactions into the given output folder')
    parser_download.add_argument('--output_folder', required=True,  help='The folder to store the csv files.')
    parser_download.add_argument('--terminal-emulator', required=False,
        default="x-terminal-emulator -e",
        help='The terminal emulator command string that gets used to run the aqbanking user-interactive session. '
        'Use an empty value »""« to not start a new terminal, but reuse the terminal running this command. '
        'Example: "xterm -e". '
        '(Default: "x-terminal-emulator -e")'
    )
    parser_download.add_argument('--prefer-valutadate', required=False, action='store_true',
        help='Uses the valuta date instead of the normal one.'
    )
    parser_download.add_argument('--disable-auto-repair', required=False, action='store_true',
        help='Disables bank specific repair steps.'
    )

    args = parser.parse_args()

    sabb = SABB()
    if (args.command == "listaccounts"):
        return sabb.get_accounts()
    elif (args.command == "bulkdownload"):
        sabb.output_folder       = args.output_folder
        sabb.terminal_emulator   = args.terminal_emulator
        sabb.prefer_valutadate   = args.prefer_valutadate
        sabb.disable_auto_repair = args.disable_auto_repair
        return sabb.download()
        #return sabb.process_context_file("<context file>")
    else:
        parser.print_help()
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
