# This file is part of facho.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.

import hashlib
from functools import reduce
import copy
import dataclasses
from dataclasses import dataclass
from datetime import datetime, date
from collections import defaultdict
import decimal
from decimal import Decimal
import typing

from ..data.dian import codelist

DECIMAL_PRECISION = 6

class AmountCurrencyError(TypeError):
    pass

@dataclass
class Currency:
    code: str

    def __eq__(self, other):
        return self.code == other.code

    def __str__(self):
        return self.code

class Collection:

    def __init__(self, array):
        self.array = array

    def filter(self, filterer):
        new_array = filter(filterer, self.array)
        return self.__class__(new_array)

    def map(self, mapper):
        new_array = map(mapper, self.array)
        return self.__class__(new_array)

    def sum(self):
        return sum(self.array)

class AmountCollection(Collection):

    def sum(self):
        total = Amount(0)
        for v in self.array:
            total += v
        return total

class Amount:
    def __init__(self, amount: int or float or str or Amount, currency: Currency = Currency('COP')):

        #DIAN 1.7.-2020: 1.2.3.1
        if isinstance(amount, Amount):
            if amount < Amount(0.0):
                raise ValueError('amount must be positive >= 0')

            self.amount = amount.amount
            self.currency = amount.currency
        else:
            if float(amount) < 0:
                raise ValueError('amount must be positive >= 0')

            self.amount = Decimal(amount, decimal.Context(prec=DECIMAL_PRECISION,
                                                          #DIAN 1.7.-2020: 1.2.1.1
                                                          rounding=decimal.ROUND_HALF_EVEN ))
            self.currency = currency

    def fromNumber(self, val):
        return Amount(val, currency=self.currency)
    
    def round(self, prec):
        return Amount(round(self.amount, prec), currency=self.currency)

    def __round__(self, prec):
        return round(self.amount, prec)

    def __str__(self):
        return str(self.float())

    def __lt__(self, other):
        if not self.is_same_currency(other):
            raise AmountCurrencyError()
        return round(self.amount, DECIMAL_PRECISION) < round(other, 2)

    def __eq__(self, other):
        if not self.is_same_currency(other):
            raise AmountCurrencyError()
        return round(self.amount, DECIMAL_PRECISION) == round(other.amount, DECIMAL_PRECISION)

    def _cast(self, val):
        if type(val) in [int, float]:
            return self.fromNumber(val)
        if isinstance(val, Amount):
            return val
        raise TypeError("cant cast to amount")
    
    def __add__(self, rother):
        other = self._cast(rother)
        if not self.is_same_currency(other):
            raise AmountCurrencyError()
        return Amount(self.amount + other.amount, self.currency)

    def __sub__(self, rother):
        other = self._cast(rother)
        if not self.is_same_currency(other):
            raise AmountCurrencyError()
        return Amount(self.amount - other.amount, self.currency)

    def __mul__(self, rother):
        other = self._cast(rother)
        if not self.is_same_currency(other):
            raise AmountCurrencyError()
        return Amount(self.amount * other.amount, self.currency)

    def is_same_currency(self, other):
        return self.currency == other.currency

    def truncate_as_string(self, prec):
        parts = str(self.float()).split('.', 1)
        return '%s.%s' % (parts[0], parts[1][0:prec].ljust(prec,'0'))

    def float(self):
        return float(round(self.amount, DECIMAL_PRECISION))
    

class Quantity:
    
    def __init__(self, val, code):
        if type(val) not in [float, int]:
            raise ValueError('val expected int or float')
        if code not in codelist.UnidadesMedida:
            raise ValueError("code [%s] not found" % (code))

        self.value = Amount(val)
        self.code = code

    def __mul__(self, other):
        return self.value * other

    def __lt__(self, other):
        return self.value < other

    def __str__(self):
        return str(self.value)

    def __repr__(self):
        return str(self)

@dataclass
class Item:
    scheme_name: str
    scheme_agency_id: str
    scheme_id: str
    description: str
    id: str


class StandardItem(Item):
    def __init__(self, id_: str, description: str = ''):
        super().__init__(id=id_,
                         description=description,
                         scheme_name='',
                         scheme_id='999',
                         scheme_agency_id='')


class UNSPSCItem(Item):
    def __init__(self, id_: str, description: str = ''):
        super().__init__(id=id_,
                         description=description,
                         scheme_name='UNSPSC',
                         scheme_id='001',
                         scheme_agency_id='10')        

        
@dataclass
class Country:
    code: str
    name: str = ''

    def __post_init__(self):
        if self.code not in codelist.Paises:
            raise ValueError("code [%s] not found" % (self.code))
        self.name = codelist.Paises[self.code]['name']

@dataclass
class CountrySubentity:
    code: str
    name: str = ''

    def __post_init__(self):
        if self.code not in codelist.Departamento:
            raise ValueError("code [%s] not found" % (self.code))
        self.name = codelist.Departamento[self.code]['name']

@dataclass
class City:
    code: str
    name: str = ''

    def __post_init__(self):
        if self.code not in codelist.Municipio:
            raise ValueError("code [%s] not found" % (self.code))
        self.name = codelist.Municipio[self.code]['name']

@dataclass
class Address:
    name: str
    street: str = ''
    city: City = City('05001')
    country: Country = Country('CO')
    countrysubentity: CountrySubentity = CountrySubentity('05')

@dataclass
class PartyIdentification:
    number: str
    dv: str
    type_fiscal: str

    def __str__(self):
        return self.number

    def __eq__(self, other):
        return str(self) == str(other)

    def full(self):
        return "%s%s" % [self.number, self.dv]

    def __post_init__(self):
        if self.type_fiscal not in codelist.TipoIdFiscal:
            raise ValueError("type_fiscal [%s] not found" % (self.type_fiscal))

@dataclass
class Responsability:
    codes: list

    def __str__(self):
        return ';'.join(self.codes)

    def __eq__(self, other):
        return str(self) == str(other)

    def __iter__(self):
        return iter(self.codes)

    def __post_init__(self):
        for code in self.codes:
            if code not in codelist.TipoResponsabilidad:
                raise ValueError("code %s not found" % (code))


@dataclass
class TaxScheme:
    code: str
    name: str = ''


    def __post_init__(self):
        if self.code not in codelist.TipoImpuesto:
            raise ValueError("code not found")
        self.name = codelist.TipoImpuesto[self.code]['name']

@dataclass
class Party:
    name: str
    ident: str
    responsability_code: typing.List[Responsability]
    responsability_regime_code: str
    organization_code: str
    tax_scheme: TaxScheme = TaxScheme('01')

    phone: str = ''
    address: Address = Address('')
    email: str = ''
    legal_name: str = ''
    legal_company_ident: str = ''
    legal_address: str = ''

    def __post_init__(self):
        if self.organization_code not in codelist.TipoOrganizacion:
            raise ValueError("organization_code not found")


@dataclass
class TaxScheme:
    code: str = '01'

    @property
    def name(self):
        return codelist.TipoImpuesto[self.code]['name']

    def __post_init__(self):
        if self.code not in codelist.TipoImpuesto:
            raise ValueError("code [%s] not found" % (self.code))


@dataclass
class TaxSubTotal:
    percent: float
    scheme: typing.Optional[TaxScheme] = None
    tax_amount: Amount = Amount(0.0)

    def calculate(self, invline):
        if self.percent is not None:
            self.tax_amount = invline.total_amount * Amount(self.percent / 100)


@dataclass
class TaxTotal:
    subtotals: list
    tax_amount: Amount = Amount(0.0)
    taxable_amount: Amount = Amount(0.0)

    def calculate(self, invline):
        self.taxable_amount = invline.total_amount

        for subtax in self.subtotals:
            subtax.calculate(invline)
            self.tax_amount += subtax.tax_amount


class TaxTotalOmit(TaxTotal):
    def __init__(self):
        super().__init__([])

    def calculate(self, invline):
        pass

@dataclass
class Price:
    amount: Amount
    type_code: str
    type: str
    quantity: int = 1

    def __post_init__(self):
        if self.type_code not in codelist.CodigoPrecioReferencia:
            raise ValueError("type_code [%s] not found" % (self.type_code))
        if not isinstance(self.quantity, int):
            raise ValueError("quantity must be int")

        self.amount *= self.quantity

@dataclass
class PaymentMean:
    DEBIT = '01'
    CREDIT = '02'

    def __init__(self, id: str, code: str, due_at: datetime, payment_id: str):
        if code not in codelist.MediosPago:
            raise ValueError("code not found")

        self.id = id
        self.code = code
        self.due_at = due_at
        self.payment_id = payment_id


@dataclass
class PrePaidPayment:
    #DIAN 1.7.-2020: FBD03
    paid_amount: Amount = Amount(0.0)


@dataclass
class BillingReference:
    ident: str
    uuid: str
    date: date

class CreditNoteDocumentReference(BillingReference):
    """
    ident: Prefijo + Numero de la factura relacionada
    uuid: CUFE de la factura electronica
    date: fecha de emision de la factura relacionada
    """


class DebitNoteDocumentReference(BillingReference):
    """
    ident: Prefijo + Numero de la factura relacionada
    uuid: CUFE de la factura electronica
    date: fecha de emision de la factura relacionada
    """

class InvoiceDocumentReference(BillingReference):
    """
    ident: Prefijo + Numero de la nota credito relacionada
    uuid: CUDE de la nota credito relacionada
    date: fecha de emision de la nota credito relacionada
    """

@dataclass
class AllowanceChargeReason:
    code: str
    reason: str

    def __post_init__(self):
        if self.code not in codelist.CodigoDescuento:
            raise ValueError("code [%s] not found" % (self.code))


@dataclass
class AllowanceCharge:
    #DIAN 1.7.-2020: FAQ03
    charge_indicator: bool = True
    amount: Amount = Amount(0.0)
    reason: AllowanceChargeReason = None

    #Valor Base para calcular el descuento o el cargo
    base_amount: typing.Optional[Amount] = Amount(0.0)
    
    # Porcentaje: Porcentaje que aplicar.
    multiplier_factor_numeric: Amount = Amount(1.0)
    
    def isCharge(self):
        return self.charge_indicator == True

    def isDiscount(self):
        return self.charge_indicator == False

    def asCharge(self):
        self.charge_indicator = True

    def asDiscount(self):
        self.charge_indicator = False

    def hasReason(self):
        return self.reason is not None

    def set_base_amount(self, amount):
        self.base_amount = amount

class AllowanceChargeAsDiscount(AllowanceCharge):
    def __init__(self, amount: Amount = Amount(0.0)):
        self.charge_indicator = False
        self.amount = amount

@dataclass
class InvoiceLine:
    # RESOLUCION 0004: pagina 155
    quantity: Quantity
    description: str
    item: Item
    price: Price
    # TODO mover a Invoice
    # ya que al reportar los totales es sobre
    # la factura y el percent es unico por type_code
    # de subtotal
    tax: typing.Optional[TaxTotal]

    allowance_charge: typing.List[AllowanceCharge] = dataclasses.field(default_factory=list)

    def add_allowance_charge(self, charge):
        if not isinstance(charge, AllowanceCharge):
            raise TypeError('charge invalid type expected AllowanceCharge')
        charge.set_base_amount(self.total_amount_without_charge)
        self.allowance_charge.add(charge)

    @property
    def total_amount_without_charge(self):
        return (self.quantity * self.price.amount)
    
    @property
    def total_amount(self):
        charge = AmountCollection(self.allowance_charge)\
            .filter(lambda charge: charge.isCharge())\
            .map(lambda charge: charge.amount)\
            .sum()

        discount = AmountCollection(self.allowance_charge)\
            .filter(lambda charge: charge.isDiscount())\
            .map(lambda charge: charge.amount)\
            .sum()

        return self.total_amount_without_charge + charge - discount

    @property
    def total_tax_inclusive_amount(self):
        # FAU06
        return self.total_amount + self.tax.tax_amount

    @property
    def total_tax_exclusive_amount(self):
        return self.tax.taxable_amount

    @property
    def tax_amount(self):
        return self.tax.tax_amount

    @property
    def taxable_amount(self):
        return self.tax.taxable_amount

    def calculate(self):
        self.tax.calculate(self)

    def __post_init__(self):
        if not isinstance(self.quantity, Quantity):
            raise ValueError("quantity must be Amount")

        if self.tax is None:
            self.tax = TaxTotalOmit()

@dataclass
class LegalMonetaryTotal:
    line_extension_amount: Amount = Amount(0.0)
    tax_exclusive_amount: Amount = Amount(0.0)
    tax_inclusive_amount: Amount = Amount(0.0)
    charge_total_amount: Amount = Amount(0.0)
    allowance_total_amount: Amount = Amount(0.0)
    payable_amount: Amount = Amount(0.0)
    prepaid_amount: Amount = Amount(0.0)

    def calculate(self):
        #DIAN 1.7.-2020: FAU14
        self.payable_amount = \
            self.tax_inclusive_amount \
            + self.allowance_total_amount \
            + self.charge_total_amount \
            - self.prepaid_amount



class NationalSalesInvoiceDocumentType(str):
    def __str__(self):
        # 6.1.3
        return '01'

class CreditNoteDocumentType(str):
    def __str__(self):
        # 6.1.3
        return '91'

class DebitNoteDocumentType(str):
    def __str__(self):
        # 6.1.3
        return '92'

class Invoice:
    def __init__(self, type_code: str):
        if str(type_code) not in codelist.TipoDocumento:
            raise ValueError("type_code [%s] not found")

        self.invoice_period_start = None
        self.invoice_period_end = None
        self.invoice_issue = None
        self.invoice_ident = None
        self.invoice_operation_type = None
        self.invoice_legal_monetary_total = LegalMonetaryTotal()
        self.invoice_customer = None
        self.invoice_supplier = None
        self.invoice_payment_mean = None
        self.invoice_payments = []
        self.invoice_lines = []
        self.invoice_allowance_charge = []
        self.invoice_prepaid_payment = []
        self.invoice_billing_reference = None
        self.invoice_type_code = str(type_code)
        self.invoice_ident_prefix = None

    def set_period(self, startdate, enddate):
        self.invoice_period_start = startdate
        self.invoice_period_end = enddate

    def set_issue(self, dtime: datetime):
        if dtime.tzname() not in ['UTC-05:00', '-05', None]:
            raise ValueError("dtime must be UTC-05:00")

        self.invoice_issue = dtime

    def _set_ident_prefix_automatic(self):
        if not self.invoice_ident_prefix:
            prefix = ''
            for idx, val in enumerate(self.invoice_ident):
                if val.isalpha():
                    prefix += val
                else:
                    break

            if len(prefix) <= 4:
                self.invoice_ident_prefix = prefix
            else:
                raise ValueError('ident prefix failed to get, expected 0  to 4 chars')

    def set_ident(self, ident: str):
        """
        identificador de factura; prefijo + consecutivo
        """
        self.invoice_ident = ident
        self._set_ident_prefix_automatic()

    def _check_ident_prefix(self, prefix):
        if len(prefix) != 4:
            raise ValueError('prefix must be 4 length')

    def set_ident_prefix(self, prefix: str):
        """
        prefijo de facturacion: ejemplo SETP
        """
        self._check_ident_prefix(prefix)
        self.invoice_ident_prefix = prefix

    def set_supplier(self, party: Party):
        self.invoice_supplier = party

    def set_customer(self, party: Party):
        self.invoice_customer = party

    def set_payment_mean(self, payment_mean: PaymentMean):
        self.invoice_payment_mean = payment_mean

    def _get_codelist_tipo_operacion(self):
        return codelist.TipoOperacionF
    
    def set_operation_type(self, operation):
        if operation not in self._get_codelist_tipo_operacion():
            raise ValueError("operation not found")

        self.invoice_operation_type = operation

    def add_allowance_charge(self, charge: AllowanceCharge):
        self.invoice_allowance_charge.append(charge)

    def add_invoice_line(self, line: InvoiceLine):
        self.invoice_lines.append(line)

    def add_prepaid_payment(self, paid: PrePaidPayment):
        self.invoice_prepaid_payment.append(paid)

    def set_billing_reference(self, billing_reference: BillingReference):
        self.invoice_billing_reference = billing_reference

    def accept(self, visitor):
        visitor.visit_payment_mean(self.invoice_payment_mean)
        visitor.visit_customer(self.invoice_customer)
        visitor.visit_supplier(self.invoice_supplier)
        for payment in self.invoice_payments:
            visitor.visit_payment(payment)
        for invline in self.invoice_lines:
            visitor.visit_invoice_line(invline)

    def _calculate_legal_monetary_total(self):
        for invline in self.invoice_lines:
            self.invoice_legal_monetary_total.line_extension_amount += invline.total_amount
            self.invoice_legal_monetary_total.tax_exclusive_amount += invline.total_tax_exclusive_amount
            #DIAN 1.7.-2020: FAU6
            self.invoice_legal_monetary_total.tax_inclusive_amount += invline.total_tax_inclusive_amount

        #DIAN 1.7.-2020: FAU08
        self.invoice_legal_monetary_total.allowance_total_amount = AmountCollection(self.invoice_allowance_charge)\
            .filter(lambda charge: charge.isDiscount())\
            .map(lambda charge: charge.amount)\
            .sum()

        #DIAN 1.7.-2020: FAU10
        self.invoice_legal_monetary_total.charge_total_amount = AmountCollection(self.invoice_allowance_charge)\
            .filter(lambda charge: charge.isCharge())\
            .map(lambda charge: charge.amount)\
            .sum()

        #DIAN 1.7.-2020: FAU12
        self.invoice_legal_monetary_total.prepaid_amount = AmountCollection(self.invoice_prepaid_payment)\
            .map(lambda paid: paid.paid_amount)\
            .sum()

        #DIAN 1.7.-2020: FAU14
        self.invoice_legal_monetary_total.calculate()

    def _refresh_charges_base_amount(self):
        if self.invoice_allowance_charge:
            for invline in self.invoice_lines:
                if invline.allowance_charge:
                    # TODO actualmente solo uno de los cargos es permitido
                    raise ValueError('allowance charge in invoice exclude invoice line')
            
        # cargos a nivel de factura
        for charge in self.invoice_allowance_charge:
            charge.set_base_amount(self.invoice_legal_monetary_total.line_extension_amount)
        
    def calculate(self):
        for invline in self.invoice_lines:
            invline.calculate()
        self._calculate_legal_monetary_total()
        self._refresh_charges_base_amount()

class NationalSalesInvoice(Invoice):
    def __init__(self):
        super().__init__(NationalSalesInvoiceDocumentType())


class CreditNote(Invoice):
    def __init__(self, invoice_document_reference: BillingReference):
        super().__init__(CreditNoteDocumentType())

        if not isinstance(invoice_document_reference, BillingReference):
            raise TypeError('invoice_document_reference invalid type')
        self.invoice_billing_reference = invoice_document_reference

    def _get_codelist_tipo_operacion(self):
        return codelist.TipoOperacionNC
    
    def _check_ident_prefix(self, prefix):
        if len(prefix) != 6:
            raise ValueError('prefix must be 6 length')

    def _set_ident_prefix_automatic(self):
        if not self.invoice_ident_prefix:
            self.invoice_ident_prefix = self.invoice_ident[0:6]


class DebitNote(Invoice):
    def __init__(self, invoice_document_reference: BillingReference):
        super().__init__(DebitNoteDocumentType())

        if not isinstance(invoice_document_reference, BillingReference):
            raise TypeError('invoice_document_reference invalid type')
        self.invoice_billing_reference = invoice_document_reference

    def _get_codelist_tipo_operacion(self):
        return codelist.TipoOperacionND

    def _check_ident_prefix(self, prefix):
        if len(prefix) != 6:
            raise ValueError('prefix must be 6 length')

    def _set_ident_prefix_automatic(self):
        if not self.invoice_ident_prefix:
            self.invoice_ident_prefix = self.invoice_ident[0:6]

