from pathlib import Path
import json
import shutil
import time
from datetime import datetime

from OpenSSL.crypto import load_certificate, FILETYPE_ASN1

from . import rc
from .routines import FileOpsManager


class Core(object):
    """ Ядро программы, управляющее основными задачами предметной области. """

    def __init__(self):
        self.settings_dict = load_settings()
        self.cert_db = load_storage()
        self.selected_cert = None
        self.flist = []
        self.fstat = {}
        self.selected_flist = []
        self.error_items = []

    def add_to_flist(self, newitem):
        if newitem in self.flist:
            return False
        else:
            self.flist.append(newitem)
            self.fstat[newitem] = is_signed(newitem, self.sign_ext)
            return True

    def remove_from_flist(self, item):
        self.flist.remove(item)
        self.fstat.pop(item)

    @property
    def crypt_path(self):
        return Path(self.settings_dict['crypt_path'])

    @property
    def crypt_args_sign(self):
        return self.settings_dict['crypt_args_sign']

    @property
    def crypt_args_verify(self):
        return self.settings_dict['crypt_args_verify']

    @property
    def console_encoding(self):
        return self.settings_dict['console_encoding']

    @property
    def sign_ext(self):
        return self.settings_dict['sign_ext']

    @property
    def file_ext(self):
        return self.settings_dict['file_ext']

    @property
    def timeout(self):
        return float(self.settings_dict['timeout'])

    def update_settings(self, **kwargs):
        settings_changed = False
        for k, v in kwargs.items():
            if self.settings_dict[k] != v:
                self.settings_dict[k] = v
                settings_changed = True
        if settings_changed:
            save_settings(self.settings_dict)

    @property
    def cert_keys(self):
        """ Получение списка доступных сертификатов. """
        return list(self.cert_db.keys())

    def import_certiticate(self, cpath):
        """ Импорт сертификата из файла.

        """
        cert_info = CertificateInfo(cpath=cpath)
        if cert_info.ckey in self.cert_db:
            raise KeyError('Сертификат уже импортирован ранее')
        return cert_info

    def append_storage(self, cpath, cert_info):
        """ Добавление информации о сертификате в хранилище.

        """
        self.cert_db[cert_info.ckey] = cert_info.cdict
        save_storage(self.cert_db)
        copy_path = Path('cert/%s.cer' % cert_info.ckey.replace(':', '-'))
        shutil.copyfile(cpath, copy_path)

    def select_certificate(self, ckey):
        self.selected_cert = CertificateInfo(cdict=self.cert_db[ckey])

    @property
    def selected_cert_digest(self):
        if self.selected_cert is None:
            return
        digest = ['Владелец сертификата:']
        digest.append('%s %s\n' % (self.selected_cert.surname,
                                   self.selected_cert.given_name))
        digest.append('Должность:')
        digest.append('%s\n' % self.selected_cert.post)
        digest.append('Организация:')
        digest.append('%s\n' % self.selected_cert.organization_alt)
        digest.append('Отпечаток SHA1:')
        digest.append('%s\n' % self.selected_cert.sha1)
        digest.append('Время действия сертификата:')
        digest.append('c %s по %s\n' % (self.selected_cert.period_start,
                                        self.selected_cert.period_end))
        return '\n'.join(digest)

    def remove_certiticate(self, ckey):
        self.cert_db.pop(ckey)
        save_storage(self.cert_db)
        copy_path = Path('cert/%s.cer' % ckey.replace(':', '-'))
        copy_path.unlink()

    @property
    def n_selected_files(self):
        return len(self.selected_flist)

    def sign_list_check(self):
        if self.selected_cert is None:
            raise CertificateNotSelected('Не выбран сертификат для подписи')
        # Список selected_flist может изменяться внешним кодом, поэтому copy:
        for fpath in self.selected_flist.copy():
            if self.fstat[fpath] == 'NOT_SIGNED':
                yield fpath, True
            else:
                yield fpath, False

    def sign_selected_files(self):
        self.error_items.clear()
        pers_data = {'name': '%s %s' % (self.selected_cert.surname,
                                        self.selected_cert.given_name),
                     'post': '%s %s' % (self.selected_cert.post,
                                        self.selected_cert.organization_alt),
                     'sha1': self.selected_cert.sha1}
        ntotal = len(self.selected_flist)
        ndone = 0
        nerr = 0
        fom = FileOpsManager(self.crypt_path, self.console_encoding,
                             self.timeout)
        fom.start()
        for fpath in self.selected_flist:
            t0 = time.perf_counter()
            if self.fstat[fpath] != 'NOT_SIGNED':
                self.remove_sign(item=fpath)
            primary_sign_path = _make_sign_path(fpath, directory=Path('.'))
            sign_path = _make_sign_path(fpath, sign_ext=self.sign_ext)
            json_path = _make_json_path(fpath)
            r = fom.sign_file(self.crypt_args_sign, pers_data,
                              fpath, primary_sign_path, sign_path, json_path)
            if r:
                nerr += 1
                self.error_items.append(fpath)
            else:
                ndone += 1

            # Обновление статуса путём удаления из flist и добавления заново:
            self.remove_from_flist(fpath)
            self.add_to_flist(fpath)

            # Обеспечение минимальной разницы двух подписей по времени создания
            tdelay = rc.min_signing_delay - (time.perf_counter() - t0)
            if tdelay > 0:
                time.sleep(tdelay)
            yield ndone, nerr, ntotal
        fom.finish()

    def check_selected_signs(self):
        self.error_items.clear()
        ntotal = len(self.selected_flist)
        ndone = 0
        nerr = 0
        fom = FileOpsManager(self.crypt_path, self.console_encoding,
                             self.timeout)
        fom.start()
        print('p3')
        for fpath in self.selected_flist:
            print('p4')
            primary_sign_path = _make_sign_path(fpath, directory=Path('.'))
            sign_path = _make_sign_path(fpath, sign_ext=self.sign_ext)
            json_path = _make_json_path(fpath)
            r = fom.check_sign(self.crypt_args_verify, self.selected_cert.sha1,
                               fpath, primary_sign_path, sign_path, json_path)
            if r:
                nerr += 1
                self.error_items.append(fpath)
            else:
                ndone += 1

            yield ndone, nerr, ntotal
        print('p5')
        fom.finish()
        print('p6')

    def remove_sign(self, item=None):
        items = self.selected_flist if item is None else [item]
        for fpath in items:
            if self.fstat[fpath] == 'NOT_SIGNED':
                yield fpath, False
                continue
            if self.fstat[fpath] == 'SIGNED':
                json_path = _make_json_path(fpath)
                json_path.unlink()
            sign_path = _make_sign_path(fpath, self.sign_ext)
            sign_path.unlink()

            # Обновление статуса путём удаления из flist и добавления заново:
            self.remove_from_flist(fpath)
            self.add_to_flist(fpath)
            yield fpath, True


class CertificateInfo(object):
    """ Объект, представляющий сведения о сертификате.
    """

    def __init__(self, cdict=None, cpath=None):
        """ Инициализация объекта, представляющего сведения о сертификате.

        Аргументы:
        ----------
        cdict: dict
            словарь со сведениями о сертификате
        cpath: pathlib.Path
            путь к файлу с сертификатом

        Примечания:
        -----------
        1. Если задан аргумент cdict, то cpath игнорируется.
        2. Если не задан ни один из аргументов cdict, cpath, вызывается
           исключение ValueError.

        """
        if cdict is None:
            if cpath is None:
                msg = 'Не задан ни один из параметров: cdict, cpath'
                raise ValueError(msg)
            cdict = self._import_cert(cpath)
        self.cdict = cdict
        self.ckey = self._make_ckey(cdict['surname'],
                                    cdict['gn'], cdict['period_end'])
        for k, v in cdict.items():
            setattr(self, k, v)

    def _import_cert(self, cert_path):
        """ Импорт сертификата из файла.

        """
        cdata = {}

        with open(cert_path, 'rb') as cert_file:
            cert_file_string = cert_file.read()
            cert = load_certificate(FILETYPE_ASN1, cert_file_string)

        cdata['sha1'] = cert.digest('sha1').decode('utf8').replace(':', '')
        cdata['serial'] = cert.get_serial_number()
        cdata['algorithm'] = cert.get_signature_algorithm().decode('utf8')

        period_start_str = cert.get_notBefore().decode('utf8').rstrip('Z')
        period_start = datetime.strptime(period_start_str, '%Y%m%d%H%M%S')
        cdata['period_start'] = period_start.strftime(rc.dtfmt)
        period_end_str = cert.get_notAfter().decode('utf8').rstrip('Z')
        period_end = datetime.strptime(period_end_str, '%Y%m%d%H%M%S')
        cdata['period_end'] = period_end.strftime(rc.dtfmt)

        for k, v in cert.get_subject().get_components():
            if k.decode('utf8') == 'GN':
                cdata['given_name'] = v.decode('utf8')
                cdata['gn'] = self._make_short_given_name(cdata['given_name'])
            elif k.decode('utf8') == 'SN':
                cdata['surname'] = v.decode('utf8')
            elif k.decode('utf8') == 'O':
                cdata['organization'] = v.decode('utf8')
            elif k.decode('utf8') == 'emailAddress':
                cdata['email'] = v.decode('utf8')

        cdata['organization_alt'] = self._make_short_orgname(cdata['organization'])
        cdata['post'] = None
        return cdata

    def update(self, attr, value):
        mutable = 'gn', 'post', 'organization_alt'
        if attr not in mutable:
            msg = 'Попытка перезаписать неизменяемое значение: %s' % attr
            raise ValueError(msg)
        else:
            self.cdict[attr] = value
            setattr(self, attr, value)

    def _make_ckey(self, surname, gn, period_end):
        return '%s %s (до %s)' % (surname, gn, period_end)

    def _make_short_given_name(self, name):
        """ Сокращение имени и отчества для отображения в списке сертификатов.

        """
        gn = []
        for part in name.split(' '):
            gn.append(part[0] + '.')
        return ' '.join(gn)

    def _make_short_orgname(self, name):
        """ Автоматическое получение сокращенного имени организации

        Организационно-правовая форма сокращается до аббревиатуры,
        название в кавычках сохраняется полностью (первая буква прописная).

        """
        i = 0
        shortname = name[i].upper()
        i += 1
        while i < len(name):
            if name[i] == ' ':
                i += 1
                if name[i] in ('"', '«'):
                    continue
                else:
                    shortname += name[i].upper()
            elif name[i] in ('"', '«'):
                i += 1
                shortname += ' «' + name[i].upper()
                i += 1
                shortname += name[i:-1].lower() + '»'
                break
            i += 1
        return shortname

    def __str__(self):

        s = ['Владелец: %s %s (%s %s)' % (self.surname, self.given_name,
                                          self.surname, self.gn)]
        if self.organization_alt in (None, self.organization):
            s.append('Организация: %s' % self.organization)
        else:
            s.append('Организация: %s (%s)' % (self.organization,
                                               self.organization_alt))
        if self.post is not None:
            s.append('Должность: %s' % self.post)
        else:
            s.append('Должность: нет данных')
        s.append('Эл. почта: %s' % self.email)
        s.append('Серийный номер: %X' % self.serial)
        s.append('Хэш SHA1: %s' % self.sha1)
        s.append('Алгоритм: %s' % self.algorithm)
        s.append('Срок действия: %s — %s' % (self.period_start,
                                             self.period_end))
        return '\n'.join(s)


class CertificateNotSelected(Exception):
    """ Перед операцией с ЭЦП (кроме удаления) не выбран сертификат. """
    pass


def load_storage():
    """ Загрузка сохранённых сведений о сертификатах из файла.

    Путь к файлу берётся из переменной rc.storage_path; если такого файла нет,
    то возвращается пустой словарь.

    """
    if rc.storage_path.is_file():
        with open(rc.storage_path, 'r', encoding='utf-8') as stfile:
            cert_db = json.load(stfile)
    else:  # если сохраненных сведений о сертификатах не найдено
        cert_db = {}
    return cert_db


def save_storage(cert_db):
    """ Сохранение сведений о сертификатах в файл.

    Путь к файлу берётся из переменной rc.storage_path.

    """
    with open(rc.storage_path, 'w', encoding='utf-8') as stfile:
        json.dump(cert_db, stfile, ensure_ascii=False, indent=4)


def load_settings():
    """ Загрузка настроек программы из файла.

    Путь к файлу берётся из переменной rc.settings_path; если такого файла нет,
    то используются настройки по умолчанию из rc.

    """
    if rc.settings_path.is_file():
        with open(rc.settings_path, 'r', encoding='utf-8') as setfile:
            settings_dict = json.load(setfile)
        settings_dict['timeout'] = float(settings_dict['timeout'])
    else:  # если файл настроек не обнаружен
        settings_dict = {'crypt_path': rc.default_crypt_path,
                         'crypt_args_sign': rc.default_crypt_args_sign,
                         'crypt_args_verify': rc.default_crypt_args_verify,
                         'console_encoding': rc.default_console_encoding,
                         'sign_ext': rc.default_sign_ext,
                         'file_ext': rc.default_file_ext,
                         'timeout': rc.default_timeout}
    return settings_dict


def save_settings(settings_dict):
    with open(rc.settings_path, 'w', encoding='utf-8') as setfile:
        json.dump(settings_dict, setfile, ensure_ascii=False, indent=4)


def _make_sign_path(fpath, sign_ext=rc.crypt_sign_ext, directory=None):
    if directory is None:
        directory = fpath.parent
    return directory / '{}.{}'.format(fpath.name, sign_ext)


def _make_json_path(fpath):
    return fpath.parent / '{}.json'.format(fpath.name)


def is_signed(fpath, sign_ext):
    """ Определение статуса файла.

    Возвращает:
    -----------
    'NOT_SIGNED' - файл не имеет открепленной цифровой подписи с расширением,
                   указанным в настройках
    'JSON_MISSING' - файл имеет открепленную цифровую подпись с расширением,
                     указанным в настройках, но нет файла JSON  с метаданными
                     для визуализации ЭЦП на сайте
    'SIGNED' - файл имеет открепленную цифровую подпись с расширением,
               указанным в настройках, и файл JSON  с метаданными для
               визуализации ЭЦП на сайте

    """
    sign_path = _make_sign_path(fpath, sign_ext)
    json_path = _make_json_path(fpath)
    if not sign_path.is_file():
        return 'NOT_SIGNED'
    elif not json_path.is_file():
        return 'JSON_MISSING'
    else:
        return 'SIGNED'
