import time, pandas as pd, numpy as np, warnings
from datetime import datetime, timedelta

"""
Each class could be called upon to, given arg:
. retrive/create an instance
  or
. retrive/create class's main value
Both will run a check for whether the object meets criterions for being of the class.
The main value is always V
"""
class ClassTemplate:
    #* If anything needs to be callable from the outside, should start with a Capital letter
    def __new__(cls, value):
        if isinstance(value, cls):
            return value
        else:
            return super().__new__(cls)
    def __init__(self, value):
        self.V = self.__class__.value(value)
        if isinstance(value, self.__class__):
            return None
        #* other needed values here
    @classmethod
    def value(cls, value):
        if isinstance(value, cls):
            value = value.V
        #* assertions here
        return value

class Market(ClassTemplate):
    def __init__(self, market):
        self.V = self.__class__.value(market)
        if isinstance(market, self.__class__):
            return None
        self.BinanceBaseUrl = 'https://fapi.binance.com/fapi/v1' if self.V=='perp' else 'https://api.binance.com/api/v3'
    @classmethod
    def value(cls, value):
        if isinstance(value, cls):
            value = value.V
        if value not in ['perp', 'spot']:
            raise ValueError("Valid options for market are 'perp' or 'spot'")
        return value
        
class USDTSymbol(ClassTemplate):
    def __init__(self, symbol):
        self.V = self.__class__.value(symbol)
        if isinstance(symbol, self.__class__):
            return None
        self.Coin = self.V[:-4].lower()
    @classmethod
    def value(cls, value):
        if isinstance(value, cls):
            value = value.V
        if not value.isupper():
            raise ValueError(f"Provided symbol is of incorrect format: {value}")
        if not value.endswith('USDT'):
            raise ValueError(f"USDTSymbol must be quoted against USDT")
        return value
    
class BinanceTf(ClassTemplate):
    def __init__(self, tf):
        self.V = self.__class__.value(tf)
        if isinstance(tf, self.__class__):
            return None
        self.Seconds = self.__class__.getSeconds(self.V)
    @classmethod
    def value(cls, value):
        if isinstance(value, cls):
            value = value.V
        if isinstance(value, int):
            value = cls.fromSeconds(value)
        #TODO: binance spot actually allows for 1s timeframes. So will have to redo this part. For now simply no warning.
        #if value not in ['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '3d', '1w', '1M']:
        #    raise ValueError(f"Invalid tf format. Received: {value}")
        return value
    def getSeconds(tf):
        num = int(tf[:-1])
        interval = tf[-1]
        map = {
            's': 1,
            'm': 60,
            'h': 60*60,
            'd': 24*60*60,
            'w': 7*24*60*60,
            'M': 30*24*60*60
        }
        multiplier = map.get(interval)
        seconds = num*multiplier
        return seconds
    def fromSeconds(seconds):
        atLeastTf = None
        numerator = seconds        
        if numerator % 60 == 0:
            numerator /= 60
            atLeastTf = 'm'
        if numerator % 60 == 0:
            numerator /= 60
            atLeastTf = 'h'
        if numerator % 24 == 0:
            numerator /= 24
            atLeastTf = 'd'
        if numerator % 7 == 0:
            if numerator % 30 == 0:
                numerator /= 30
                atLeastTf = 'M'
            else:
                numerator /= 7
                atLeastTf = 'w'
        tf = f"{int(numerator)}{atLeastTf}"
        return tf
    
class Timestamp(ClassTemplate):
    def __init__(self, timestamp):
        self.V = self.__class__.value(timestamp)
        if isinstance(timestamp, self.__class__):
            return None
        self.Ns = self.convertAnyToUnixNs(self.V)
        self.S = int(round(self.Ns/1_000_000_000))
        self.Ms = int(round(self.Ns/1_000_000))
        self.Us = int(round(self.Ns/1_000))
        self.Datetime = datetime.fromtimestamp(self.Ns/1_000_000_000)
        self.Isoformat = self.Datetime.isoformat()
    @classmethod
    def value(cls, value):
        if isinstance(value, cls):
            value = value.V # returning the original Timestamp value, because there is no main value here
        return value
    def convertAnyToUnixNs(self, timestamp):
        unix_ns = None
        # checks if isoformat
        try:
            dt = datetime.fromisoformat(timestamp)
            unix_ns = dt.timestamp() * 1_000_000_000
        except:pass
        # checks if datetime
        try: unix_ns = timestamp.timestamp()*1_000_000_000
        except:pass
        # checks if unix
        try:
            if len(str(round(timestamp))) == 10:
                unix_ns = timestamp * 1_000_000_000
            if len(str(round(timestamp))) == 13:
                unix_ns = timestamp * 1_000_000
            if len(str(round(timestamp))) == 16:
                unix_ns = timestamp * 1_000
            if len(str(round(timestamp)))==19:
                unix_ns = timestamp
            assert 0 <= unix_ns/1_000_000_000 <= time.time()
        except:pass
            
        assert unix_ns != None, f"Provided timestamp type isn't supported.\nProvided: {timestamp}\nSupported: [unix_s, unix_ms, unix_us, unix_ns, datetime, isoformat], where only isoformat can be string"
        return unix_ns
    
    #todo here should be a __minus__ (or something like that) method, returning a new self.__class__ object with all values diminished by provided number of seconds
    
    #todo here should be a implementation of roundinig down to a given timeframe
    
class ClosesDf(ClassTemplate):
    def __init__(self, closesDf):
        self.V = self.__class__.value(closesDf)
        if isinstance(closesDf, self.__class__):
            return None
    @classmethod
    def value(cls, value):
        if isinstance(value, cls):
            value = value.V
        assert isinstance(value, pd.DataFrame)
        for column in value.columns:
            assert USDTSymbol(column)
        assert isinstance(value.index[0], datetime)
        assert np.any(value.iloc[:, 0].values == 0), f"Data does not go through 0.0, so assuming the df to be not normalized. Normalize the dataframe. Currently first column is: {value.iloc[:, 0].values}"
        return value
        
class Klines(ClassTemplate):
    def __init__(self, klinesDf):
        self.V = self.__class__.value(klinesDf)
        if isinstance(klinesDf, self.__class__):
            return None
        self.Market:Market = None
        try:self.Tf:BinanceTf = self.determineTf()
        except:self.Tf = None
        self.Full = False #todo
        self.Normalized = False
    @classmethod
    def value(cls, value):
        if isinstance(value, cls):
            value = value.V
        assert isinstance(value, pd.DataFrame)
        assert isinstance(value.index[0], datetime)
        mandatoryColumns = ['open', 'high', 'low', 'close', 'volume']
        for column in mandatoryColumns:
            assert column in value.columns
        value[mandatoryColumns] = value[mandatoryColumns].apply(pd.to_numeric)
        return value
    def Normalize(self, dtTimestamp:Timestamp=None):
        if not self.Normalized:
            df = self.V
            zeroIndex = df.index[0] if dtTimestamp == None else Timestamp(dtTimestamp).datetime
            columns = ['open', 'high', 'low', 'close', 'oi', 'lsr']
            for column in columns:
                if column in df.columns:
                    zeroValue = df.loc[zeroIndex, column]
                    df[column] = df[column].apply(lambda x: round(np.log( x /zeroValue), 3))
    def determineTf(self):
        secondIndexTimestamp = Timestamp(self.V.index[1])
        thirdIndexTimestamp = Timestamp(self.V.index[2])
        seconds = thirdIndexTimestamp.S - secondIndexTimestamp.S
        return BinanceTf(seconds).V
                
class SymbolsKlines(ClassTemplate):
    def __init__(self, klinesDict):
        self.V = self.__class__.value(klinesDict)
        if isinstance(klinesDict, self.__class__):
            return None
    @classmethod
    def value(cls, value):
        if isinstance(value, cls):
            value = value.V
        assert isinstance(value, dict)
        assert len(value) >= 1
        for symbol, df in value.items():
            _, _ = USDTSymbol(symbol), Klines(df)
        return value
    def Normalize(self, dtTimestamp:Timestamp=None):
        for symbol, k in self.V.items():
            k.Normalize(dtTimestamp)
    def ToOpensDf(self) -> ClosesDf:
        self.Normalize()
        NormalizedKlinesDict = self.V
        opensDf = pd.DataFrame({symbol: k.V['close'] for symbol, k in NormalizedKlinesDict.items()})
        return opensDf
