# -*- coding: utf-8 -*-
import base64
import hashlib
import json
import logging
import time
from datetime import datetime, timedelta, timezone
from enum import Enum
from importlib import resources
from uuid import uuid1

import xmltodict
from OpenSSL import crypto

from . import templates
from . import utils

logging.basicConfig(level=logging.INFO)
_logger = logging.getLogger(__name__)


TEMPLATES = {
    'Envelope': resources.read_text(templates.common, 'Envelope.xml'),
    'KeyInfo': resources.read_text(templates.common, 'KeyInfo.xml'),
    'Signature': resources.read_text(templates.common, 'Signature.xml'),
    'SignedInfo': resources.read_text(templates.common, 'SignedInfo.xml'),
    'Timestamp': resources.read_text(templates.login, 'Timestamp.xml'),
    'LoginEnvelope': resources.read_text(templates.login, 'Envelope.xml'),
    'SolicitaDescarga': resources.read_text(templates.query, 'SolicitaDescarga.xml'),
    'VerificaSolicitudDescarga': resources.read_text(templates.verify, 'VerificaSolicitudDescarga.xml'),
    'PeticionDescargaMasivaTercerosEntrada': resources.read_text(templates.download, 'PeticionDescargaMasivaTercerosEntrada.xml'),
}


def ensure_login(f):
    def wrapper(*args, **kwargs):
        self = args[0]
        self.login()
        res = f(*args, **kwargs)
        return res
    return wrapper


class SAT:
    '''Class to make a connection to the SAT'''

    class DownloadType:
        '''Helper to select the download type'''
        ISSUED = 'RfcEmisor'
        RECEIVED = 'RfcReceptor'

    class RequestType:
        '''Helper to select the request type'''
        CFDI = 'CFDI'
        METADATA = 'Metadata'

    class NoRFCException(Exception):
        '''If not valid RFC founded in the certificate'''

    class NoIssuerException(Exception):
        '''If not valid Issuer founded in the certificate'''

    class RequestException(Exception):
        '''If there is a problem in the request'''

    class QueryException(Exception):
        '''If not valid query'''

    cert = None
    key = None
    password = None
    key_pem = None
    cert_pem = None
    certificate = None
    rfc = None
    token = None
    token_expires = None

    def __init__(self, cert: bytes, key: bytes, password: str) -> None:
        '''Loads the certificate, key file and password to stablish the connection to the SAT

        Creates a object to manage the SAT connection.

        Args:
            cert (bytes): DER Certificate in raw binary
            key (bytes): DER Key Certificate in raw binary
            password (str): Key password in plain text (utf-8)
        '''
        self.cert = utils.binary_to_utf8(cert)
        self.key = utils.binary_to_utf8(key)
        self.password = password
        self._load_certs()
        self._compute_data_from_cert()
        _logger.info('Data correctly loaded')

    def _load_certs(self):
        '''Loads the PEM version of the certificate and key file, also loads the crypto certificate

        Convert the `cert` and `key` from DER to PEM and creates the real certificate (X509)
        '''
        self.key_pem = utils.der_to_pem(self.key, type='ENCRYPTED PRIVATE KEY')
        self.cert_pem = utils.der_to_pem(self.cert, type='CERTIFICATE')
        self.certificate = crypto.load_certificate(crypto.FILETYPE_PEM, self.cert_pem)

    def _compute_data_from_cert(self):
        '''Gets the RFC and Issuer directly from the certificate'''
        self._get_rfc_from_cert()
        self._get_issuer_from_cert()

    def _get_rfc_from_cert(self):
        '''Gets the RFC from the certificate

        Raises:
            NoRFCException: If not RFC founded
        '''
        subject_components = self.certificate.get_subject().get_components()
        for c in subject_components:
            if c[0] == b'x500UniqueIdentifier':
                self.rfc = c[1].decode('UTF-8').split(' ')[0]
                _logger.debug(f'RFC {self.rfc} loaded')
                break
        else:
            raise self.NoRFCException()

    def _get_issuer_from_cert(self):
        '''Gets the Issuer from the certificate

        Raises:
            NoIssuerException: If not Issuer founded
        '''
        self.certificate.issuer = ','.join([
            f'{c[0].decode("UTF-8")}={c[1].decode("UTF-8")}'
            for c in self.certificate.get_issuer().get_components()
        ])
        if not self.certificate.issuer:
            raise self.NoIssuerException()
        _logger.debug(f'Issuer {self.certificate.issuer} loaded')

    def _token_expired(self) -> bool:
        '''Checks if the token expiration date is yet to come

        Returns:
            bool: True if not token or if is expired
        '''
        if not self.token or not self.token_expires:
            _logger.debug('Token expired')
            return True
        return self.token_expires > datetime.utcnow()

    def _create_common_envelope(self, template: str, data: dict) -> str:
        _logger.debug('Creating Envelope')
        _logger.debug(f'{template}')
        _logger.debug(f'{data}')
        query_data, query_data_signature = utils.prepare_template(template, data)
        digest_value = utils.digest(query_data)
        signed_info = utils.prepare_template(TEMPLATES['SignedInfo'], {
            'uri': '',
            'digest_value': digest_value,
        })
        key_info = utils.prepare_template(TEMPLATES['KeyInfo'], {
            'issuer_name': self.certificate.issuer,
            'serial_number': self.certificate.get_serial_number(),
            'certificate': self.cert,
        })
        signature_value = self.sign(signed_info)
        signature = utils.prepare_template(TEMPLATES['Signature'], {
            'signed_info': signed_info,
            'signature_value': signature_value,
            'key_info': key_info,
        })
        envelope_content = utils.prepare_template(query_data_signature, {
            'signature': signature,
        })
        envelope = utils.prepare_template(TEMPLATES['Envelope'], {
            'content': envelope_content,
        })
        _logger.debug('Final Envelope')
        _logger.debug(f'{envelope}')
        return envelope

    def sign(self, data) -> str:
        '''Signs the `data` using SHA1 with the `key_pem` content'''
        _logger.debug(f'Signing {data}')
        private_key = crypto.load_privatekey(crypto.FILETYPE_PEM, self.key_pem, passphrase=self.password)
        signed_data = utils.binary_to_utf8(crypto.sign(private_key, data, 'sha1'))
        return signed_data

    def login(self, created: datetime = None, expires: datetime = None, uuid: str = None):
        created = created or datetime.utcnow()
        expires = expires or created + timedelta(minutes=5)
        uuid = uuid or f'uuid-{uuid1()}-1'
        '''If the current token is invalid, tries to login

        Args:
            created (datetime, optional): Creation date to be used in the session. Defaults to datetime.utcnow().
            expires (datetime, optional): Expiration date to be used in the session. Defaults to datetime.utcnow()+timedelta(minutes=5).
            uuid (str, optional): UUID date to be used in the session. Defaults to f'uuid-{uuid1()}-1'.
        '''
        if self._token_expired():
            _logger.debug('Token expired, creating a new one')
            self.token_expires = expires
            self._login(created, expires, uuid)
            _logger.debug('New token created')

    def _login(self, created: datetime, expires: datetime, uuid: str):
        '''Send login request to the SAT

        Args:
            created (datetime): Creation date to be used in the session
            expires (datetime): Expiration date to be used in the session
            uuid (str): UUID date to be used in the session

        Raises:
            RequestException: If there was an error in the request
        '''
        request_content = self._get_login_soap_body(created, expires, uuid)
        response = utils.consume(
            'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica',
            'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc',
            request_content,
        )
        if response.status_code != 200:
            raise self.RequestException(response.status_code, response.reason, request_content)
        else:
            self._get_login_data(utils.remove_namespaces(response.content.decode('UTF-8')))

    def _get_login_soap_body(self, created_object: datetime, expires_object: datetime, uuid: str) -> str:
        '''Creates the request body to be used in login

        Args:
            created_object (datetime): Creation date to be used in the session
            expires_object (datetime): Expiration date to be used in the session
            uuid (str): UUID date to be used in the session

        Returns:
            str: Content body
        '''
        created = created_object.isoformat()
        expires = expires_object.isoformat()
        timestamp = utils.prepare_template(TEMPLATES['Timestamp'], {
            'created': created,
            'expires': expires,
        })
        digest_value = utils.digest(timestamp)
        signed_info = utils.prepare_template(TEMPLATES['SignedInfo'], {
            'uri': '#_0',
            'digest_value': digest_value,
        })
        signature_value = self.sign(signed_info)
        _logger.debug(f'''Creating Login Envelope with the next data
            "created": {created},
            "expires": {expires},
            "uuid": {uuid},
        ''')
        envelope = utils.prepare_template(TEMPLATES['LoginEnvelope'], {
            'binary_security_token': self.cert,
            'created': created,
            'digest_value': digest_value,
            'expires': expires,
            'signature_value': signature_value,
            'uuid': uuid,
        })
        return envelope

    def _get_login_data(self, response: str) -> str:
        '''Gets the token from the raw response'''
        response_dict = xmltodict.parse(response)
        self.token = response_dict['Envelope']['Body']['AutenticaResponse']['AutenticaResult']

    @ensure_login
    def query(self, start: datetime, end: datetime, download_type: str, request_type: str) -> str:
        '''Creates a Query in the SAT system'''
        request_content = self._get_query_soap_body(start, end, download_type, request_type)
        response = utils.consume(
            'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga',
            'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc',
            request_content,
            token=self.token,
        )
        if response.status_code != 200:
            raise self.RequestException(response.status_code, response.reason, request_content)
        else:
            try:
                id = self._get_query_id(utils.remove_namespaces(response.content.decode('UTF-8')))
                return id
            except:
                raise  # TODO

    def _get_query_soap_body(self, start: datetime, end: datetime, download_type: str, request_type: str):
        '''Creates the SOAP body to the query request'''
        start = start.isoformat()
        end = end.isoformat()
        data = {
            'start': start,
            'end': end,
            'rfc': self.rfc,
            'download_type': download_type,
            'request_type': request_type,
            'signature': '',
        }
        envelope = self._create_common_envelope(TEMPLATES['SolicitaDescarga'], data)
        return envelope

    def _get_query_id(self, response: str) -> str:
        '''Gets the Query ID from the raw response'''
        response_dict = xmltodict.parse(response)
        result = response_dict['Envelope']['Body']['SolicitaDescargaResponse']['SolicitaDescargaResult']
        status_code = int(result.get('@CodEstatus', -1))
        if status_code == 5000:
            id = result['@IdSolicitud']
            return id
        message = result['@Mensaje']
        raise self.QueryException(status_code, message)

    @ensure_login
    def verify(self, query_id: str) -> dict:
        '''Checks the status of a Query'''
        request_content = self._get_verify_soap_body(query_id)
        response = utils.consume(
            'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga',
            'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc',
            request_content,
            token=self.token,
        )
        if response.status_code != 200:
            raise self.RequestException(response.status_code, response.reason, request_content)
        else:
            try:
                data = self._get_verify_data(utils.remove_namespaces(response.content.decode('UTF-8')))
                return data
            except:
                raise  # TODO

    def _get_verify_soap_body(self, query_id: str) -> str:
        '''Creates the SOAP body to check the query status'''
        data = {
            'rfc': self.rfc,
            'query_id': query_id,
            'signature': '',
        }
        envelope = self._create_common_envelope(TEMPLATES['VerificaSolicitudDescarga'], data)
        return envelope

    def _get_verify_data(self, response: str) -> dict:
        '''Gets the Query ID from the raw response'''
        response_dict = xmltodict.parse(response)
        result = response_dict['Envelope']['Body']['VerificaSolicitudDescargaResponse']['VerificaSolicitudDescargaResult']
        data = {
            'EstadoSolicitud': result['@EstadoSolicitud'],
            'CodEstatus': result['@CodEstatus'],
            'Mensaje': result['@Mensaje'],
            'CodigoEstadoSolicitud': result['@CodigoEstadoSolicitud'],
            'NumeroCFDIs': result['@NumeroCFDIs'],
            'IdsPaquetes': [result['IdsPaquetes']] if result['@EstadoSolicitud'] == '3' else '',  # TODO Check what happens when multiple ids
        }
        return data

    @ensure_login
    def download(self, package_ids: (list, str)) -> dict:
        '''Checks the status of a Query'''
        if type(package_ids) == str:
            package_ids = [package_ids]
        downloads = {}
        for package_id in package_ids:
            request_content = self._get_download_soap_body(package_id)
            response = utils.consume(
                'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaTercerosService/Descargar',
                'https://cfdidescargamasiva.clouda.sat.gob.mx/DescargaMasivaService.svc',
                request_content,
                token=self.token,
            )
            if response.status_code != 200:
                raise self.RequestException(response.status_code, response.reason, request_content)
            else:
                try:
                    downloads[package_id] = self._get_download_data(utils.remove_namespaces(response.content.decode('UTF-8')))
                except:
                    raise  # TODO
        return downloads

    def _get_download_soap_body(self, package_id: str) -> dict:
        '''Creates the SOAP body to check the query status'''
        data = {
            'rfc': self.rfc,
            'package_id': package_id,
            'signature': '',
        }
        envelope = self._create_common_envelope(TEMPLATES['PeticionDescargaMasivaTercerosEntrada'], data)
        return envelope

    def _get_download_data(self, response: str) -> bytes:
        '''Gets the Download data from the raw response'''
        response_dict = xmltodict.parse(response)
        package = response_dict['Envelope']['Body']['RespuestaDescargaMasivaTercerosSalida']['Paquete']
        return package and base64.b64decode(package)

    def wait_query(self, query_id: str, retries: int = 10, wait_seconds: int = 2) -> list:
        for _ in range(retries):
            verification = self.verify(query_id)
            if verification['EstadoSolicitud'] == '3':
                return verification['IdsPaquetes']
            time.sleep(wait_seconds)
        else:
            raise TimeoutError('The query is not yet resolved')
