from collections import namedtuple
import datetime
import logging
import random
import string
from typing import Tuple

import attr

from django.conf import settings
from phonenumbers import (
    format_number,
    NumberParseException,
    parse,
    PhoneNumberFormat,
)
from staff.audit.factory import create_log, AUDIT_ACTIONS
from staff.lib import attr_ext, sms, requests
from staff.lib.db import atomic
from staff.lib.tvm2 import TVM_SERVICE_TICKET_HEADER, get_tvm_ticket_by_deploy
from staff.lib.utils.ordered_choices import OrderedChoices
from staff.person import models


AttachResult = namedtuple('AttachResult', ['ok', 'status'])

logger = logging.getLogger(__name__)
_randomizer = random.SystemRandom()


VERIFY_ANSWERS = OrderedChoices(
    ('OK', 'ok'),
    ('NO_WAIT_ATTACHMENT', 'no_wait_attachment'),
    ('INCORRECT_CODE', 'incorrect_code'),
    ('EXPIRED', 'expired'),
    ('MAX_VALIDATE_TRIES_EXCEEDED', 'max_validate_tries_exceeded'),
    ('OEBS_ERROR', 'oebs_error'),
)


class OebsConnectorException(Exception):
    pass


class OebsConnector:

    HAS_SIGNED_AGREEMENT = 'getDssStatementSignedInfo'
    UPDATE_DSS_USER_PHONE = 'updateDssUserPhone'
    GET_DSS_DOC_INFO = 'getDssDocInfo'
    GET_DSS_CERTIFICATION_STATUS = 'getDssCertificationStatus'

    @staticmethod
    def _get_url(page: str) -> str:
        return f'https://{settings.OEBS_HOST}/rest/{page}'

    def _call(self, method: str, data: dict, login='', timeout=settings.OEBS_DIGITAL_SIGN_TIMEOUT) -> requests.Response:
        if not hasattr(self, method.upper()):
            raise OebsConnectorException(f'Method {method} not defined')
        return requests.post(
            url=self._get_url(getattr(self, method.upper())),
            json=data,
            headers={TVM_SERVICE_TICKET_HEADER: get_tvm_ticket_by_deploy('oebs-api')},
            timeout=timeout,
            log_message=f'{method} oebs for {login}',
        )

    def has_signed_agreement(self, login: str):
        return self._call('has_signed_agreement', {'login': [login]}, login)

    def update_dss_user_phone(self, login: str, phone: str):
        return self._call('update_dss_user_phone', {'login': login, 'phonenumber': phone}, login)

    def get_dss_doc_info(self, login: str):
        return self._call('get_dss_doc_info', {'filters': {'login': login, 'signStatus': 'unsigned'}}, login)

    def get_dss_certification_status(self, login: str):
        return self._call('get_dss_certification_status', {'login': login}, login)


@attr.s
class OebsDocsStatus(object):
    @attr.s
    class Proc(object):
        @attr.s
        class Doc(object):
            deadline = attr.ib(converter=attr_ext.oebs_str_to_datetime, default=None)

        doc = attr.ib(converter=attr_ext.list_of(Doc), factory=list)

    proc = attr.ib(converter=attr_ext.list_of(Proc), factory=list)
    unsignedDocCount = attr.ib(converter=attr.converters.optional(int), default=None)
    unsignedDssDocCount = attr.ib(converter=attr.converters.optional(int), default=None)


@atomic
def attach_phone(object_type, object_id, phone_number, initiator, **kwargs) -> AttachResult:
    """
    Отправляет на телефон `phone_number` код подтверждения для выпуска ЭЦП.

    :param object_type: тип объекта, к которому привязан телефон (VERIFY_CODE_TYPES)
    :param object_id: id объекта, к которому привязан телефон
    :param phone_number: номер телефона
    :param initiator: инициатор действия
    :param kwargs: дополнительные параметры для VerifyCode
    :return: статус отправки sms
    """
    code = ''.join(_randomizer.sample(string.digits, settings.VERIFY_CODE_LENGTH))
    status = sms.send(phone_number, 'Verify code: {}'.format(code))

    create_log(
        objects={
            'object_type': object_type,
            'status': status,
        },
        who=initiator,
        action=AUDIT_ACTIONS.ATTACH_PHONE_TO_DS,
        primary_key=object_id,
    )

    if status == sms.RESPONSES.OK:
        existing_codes = models.VerifyCode.objects.filter(
            object_type=object_type,
            object_id=object_id,
            state__in=(
                models.VERIFY_STATE.WAIT,
                models.VERIFY_STATE.VERIFIED,
            ),
        )
        existing_codes.update(state=models.VERIFY_STATE.FAILED)
        models.VerifyCode.objects.create(
            object_type=object_type,
            object_id=object_id,
            phone_number=phone_number,
            code=code,
            **kwargs,
        )

    return AttachResult(ok=status == sms.RESPONSES.OK, status=status)


def attach_staff_phone(phone: models.StaffPhone):
    """
    Шорткат attach_phone для телефона из анкеты сотрудника
    """
    return attach_phone(
        object_type=models.VERIFY_CODE_OBJECT_TYPES.STAFF,
        object_id=phone.staff_id,
        phone_number=phone.number,
        initiator=phone.staff.user,
        person=phone.staff,
        phone=phone,
    )


def _verify_code(object_type, object_id, code):
    """
    Проверяет, соответствует ли код `code` тому, что мы отправили по sms
    через attach_phone для данных object_type и object_id.

    :param object_type: тип объекта, к которому привязан код (VERIFY_CODE_TYPES)
    :param object_id: id объекта, к которому привязан телефон
    :param code: код из sms
    :return: статус проверки (VERIFY_STATE), объект VerifyCode
    """
    state = models.VERIFY_STATE
    code_models = list(
        models.VerifyCode.objects
        .filter(
            object_type=object_type,
            object_id=object_id,
            state=state.WAIT,
        )
        .select_related('person', 'phone')
    )
    if not code_models:
        return VERIFY_ANSWERS.NO_WAIT_ATTACHMENT, None
    assert len(code_models) == 1, 'More than one wait verify code for user'
    code_model = code_models[0]
    result = VERIFY_ANSWERS.OK

    if code != code_model.code:
        result = VERIFY_ANSWERS.INCORRECT_CODE
        code_model.tries += 1
    if code_model.tries >= settings.MAX_VALIDATE_TRIES:
        result = VERIFY_ANSWERS.MAX_VALIDATE_TRIES_EXCEEDED
    time_from_sending = datetime.datetime.now() - code_model.created_at
    if time_from_sending.total_seconds() > settings.VERIFY_CODE_EXPIRATION:
        result = VERIFY_ANSWERS.EXPIRED
        code_model.state = state.FAILED
    if result == VERIFY_ANSWERS.OK:
        code_model.state = state.VERIFIED

    code_model.save(update_fields=['state', 'tries', 'modified_at'])
    return result, code_model


@atomic
def verify_code(object_type, object_id, code, initiator):
    result, code_model = _verify_code(object_type, object_id, code)
    create_log(
        objects={'status': result},
        who=initiator,
        action=AUDIT_ACTIONS.VERIFY_PHONE_CODE_DS,
        primary_key=code_model and code_model.id,
    )
    return result, code_model


def verify_code_by_person(person: models.Staff, code: str):
    return verify_code(models.VERIFY_CODE_OBJECT_TYPES.STAFF, person.id, code, person.user)


def _connect_phone_in_oebs(login, number) -> Tuple[bool, str]:
    try:
        number = format_number(
            parse(number, 'RU'),
            PhoneNumberFormat.E164,
        )
    except NumberParseException:
        number = ''
    try:
        response = OebsConnector().update_dss_user_phone(login, number)
        result = response.status_code == 200 and response.json().get('status') != 'ERROR'
        resp_text = response.json().get('messages') or response.text
    except Exception as exc:
        result = False
        resp_text = str(exc)

    if not result:
        err = 'OEBS answered with error after trying to attach phone:\n{}'.format(resp_text)
        logger.warning(err)

    return result, resp_text


def prepare_digital_sign(code: models.VerifyCode, initiator, login, phone_number) -> Tuple[bool, str]:
    connected, oebs_resp = _connect_phone_in_oebs(login, phone_number)
    create_log(
        objects={'status': connected},
        who=initiator,
        action=AUDIT_ACTIONS.CONNECT_PHONE_IN_OEBS_TO_DS,
        primary_key=code.id,
    )
    return connected, oebs_resp


def create_digital_sign(code: models.VerifyCode, initiator) -> Tuple[bool, str]:
    """
    Выпуск ЭЦП
    """
    connected, oebs_resp = prepare_digital_sign(code, initiator, code.person.login, code.phone.number)
    if not connected:
        return False, oebs_resp

    code.state = models.VERIFY_STATE.CONFIRMED
    code.save()

    # Только один телефон может быть привязан к ЭЦП
    # Поэтому старый телефон нужно отвязать, а новый привязать
    unmark_existing_phones_for_digital_sign(code.person)
    code.phone.for_digital_sign = True
    code.phone.save()

    return True, oebs_resp


def unmark_existing_phones_for_digital_sign(person: models.Staff):
    existing_phones = models.StaffPhone.objects.filter(staff=person, for_digital_sign=True)
    # STAFF-15523: не шлем смс
    existing_phones.update(for_digital_sign=False)


def check_agreement_sign(login):
    try:
        response = OebsConnector().has_signed_agreement(login).json()
        return bool(response and response[0]['statementSigned'])
    except Exception as exc:
        logger.warning('Failed to check agreement sign: {}'.format(exc))
        return None


def get_docs_status_from_oebs(login: str) -> OebsDocsStatus:
    try:
        response = OebsConnector().get_dss_doc_info(login).json()
        if not isinstance(response, dict):
            raise TypeError('Oebs returned wrong json type: {}'.format(response.__class__))
        return attr_ext.from_kwargs(OebsDocsStatus, **response)
    except Exception as exc:
        logger.warning('Failed to receive digital sign status: {}'.format(exc))
        raise


@attr.s
class DSSCertificationStatus(object):
    @attr.s
    class Period(object):
        effectiveStartDate = attr.ib(default=None, converter=attr_ext.oebs_str_to_date)
        effectiveEndDate = attr.ib(default=None, converter=attr_ext.oebs_str_to_date)

    existsCertificate = attr.ib(converter=attr_ext.oebs_str_to_bool)
    hasEmpCert = attr.ib(converter=attr_ext.oebs_str_to_bool)
    period = attr.ib(converter=attr_ext.ensure_cls(Period), factory=Period)
    ticket = attr.ib(default=None)
    ticketForReissue = attr.ib(default=None)


def get_digital_sign_certification_status_from_oebs(login: str) -> DSSCertificationStatus:
    try:
        response = OebsConnector().get_dss_certification_status(login).json()
        if 'ERROR' in response:
            raise ValueError(response)
        if not all(key in response for key in ('existsCertificate', 'hasEmpCert')):
            raise ValueError('malformed OEBS reply')
        return attr_ext.from_kwargs(DSSCertificationStatus, **response)
    except Exception as exc:
        logger.warning('Failed to get digital sign certification status: {}'.format(exc))
        raise
