from typing import Union
import os
import json
import copy
import unicodedata
from bintang.table_base import Base_Table
from bintang.table_mem import Memory_Table, Path_Table
from bintang.table_sqlite import Sqlite_Table
from bintang.table_fsql import From_SQL_Table
from bintang.table_fcsv import From_CSV_Table
from bintang import iterdict
from pathlib import Path
from bintang.log import log
import unicodedata
import datetime


def default_stringify(value):
    """ convert value to string, normalize it and lower case it.
        This is used to normalize the value before comparing it.
        It aimes to cover default process that SequenceMatcher does not handle.
    """
    if isinstance(value, str):
        value = value.strip()
        value =unicodedata.normalize("NFKD", value.casefold()) #experiment
        return value.lower()
    else:
        return str(value)


# define get_fuzzy_ratio() to use rapidfuzz or difflib depending on package availability
try:
    from rapidfuzz import fuzz , process, utils
    def get_fuzzy_ratio(value1, value2, default_process=True):
        """ get string similarity between two values.
            using rapidfuzz package only
        """
        if value1 is None or value2 is None:
            return 0  
        elif default_process:
            return  round(fuzz.ratio(value1, value2, processor=utils.default_process) /100.0 ,4)
        else:
            return  round(fuzz.ratio(value1, value2) /100.0, 4)
except ImportError as e:
    log.warning(f'warning! bintang.get_fuzzy_ratio() could not find rapidfuzz, difflib.SequenceMatcher would be used.')
    # define get_fuzzy_ratio to use difflib
    from difflib import SequenceMatcher
    def get_fuzzy_ratio(value1, value2, default_process=True):
        """ get string similarity between two values.
            using python difflib's SequenceMatcher only
        """
        if default_process == True:
            value1 = default_stringify(value1)
            value2 = default_stringify(value2)
            return round(SequenceMatcher(None, value1, value2).ratio(), 4)
        else:
            return round(SequenceMatcher(None, value1, value2).ratio(), 4)


def match_case(value1, value2):
    "Ascii case sensitive matching"
    if value1 == value2:
        return True
    

def match_caseless(value1, value2):
    "Ascii ase insensitive matching"
    if isinstance(value1, str) and isinstance(value2, str):
        if value1.lower() == value2.lower():
            return True
    elif isinstance(value1, str) or isinstance(value2, str):
        if str(value1) == str(value2):
            return True    
    else:
        if value1 == value2:
            return True    



def _normalize_caseless(string):
    return unicodedata.normalize("NFKD", string.casefold())


def match(value1, value2):
    """Unicode case insensitive matching.
    the ultimate goal is to get result regardless data type"""
    if isinstance(value1, str) and isinstance(value2, str):
        if _normalize_caseless(value1) == _normalize_caseless(value2):
            return True
    elif isinstance(value1, str) or isinstance(value2, str): # 1 vs '1' is True
        if str(value1) == str(value2):
            return True
    else:
        if value1 == value2:
            return True  

    
def get_similar_values(value, similar_values, min_ratio=0.6):
        res = []
        for col in similar_values:
            ratio = get_fuzzy_ratio(col, value)
            print(col,value)
            print(ratio)
          
            if ratio >= min_ratio:
                res.append((col,ratio))
        res_sorted = sorted(res, key=lambda tup: tup[1], reverse=True)
        return [x[0] for x in res_sorted] # just extract the name, not ratio


def get_wb_type_toread(wb):
    """
        get workbook type so read_excel() will know which func/attrb to use.
    """
    if str(type(wb)) == "<class 'openpyxl.workbook.workbook.Workbook'>":
        return 'openpyxl'
    elif str(type(wb)) == "<class 'xlrd.book.Book'>":
        return 'xlrd'
    else:
        raise ValueError('Sorry, for reading excel file, only openpyxl or xlrd workbook accepted!')
    

def get_wb_type_towrite(wb):
    """
        get workbook type so to_excel() know which func/attrb to use.
    """
    if str(type(wb)) == "<class 'openpyxl.workbook.workbook.Workbook'>":
        return 'openpyxl'
    elif str(type(wb)) == "<class 'xlwt.Workbook.Workbook'>":
        return 'xlwt'
    else:
        raise ValueError('Sorry, for writing excel file, only openpyxl or xlwt workbook accepted!')


def gen_ts_string(fmt: str = None) -> str:
    """
    Generates a timestamp string. Defaults to YYYYMMDDTHHMMSS 
    (ISO 8601 basic format) if no format is provided.
    """
    now = datetime.datetime.now()
    if fmt is None:
        return now.strftime('%Y%m%dT%H%M%S')
    return now.strftime(fmt)


def gen_ts_path(file_path: str, fmt: str = '%Y%m%dT%H%M%S') -> str:
    """
    Appends a timestamp to a filename while preserving the directory path.
    Example: 'c:/data/source.csv' -> 'c:/data/source_20260314T093500.csv'
    """
    original_path = Path(file_path)
    timestamp = datetime.datetime.now().strftime(fmt)
    
    # original_path.parent gives you "c:\data"
    # original_path.stem gives you "incoming_data"
    # original_path.suffix gives you ".csv"
    new_filename = f"{original_path.stem}_{timestamp}{original_path.suffix}"
    
    # joinpath puts the folder and the new filename back together correctly
    return str(original_path.parent.joinpath(new_filename))


class Bintang():
    def __init__(self, name=None, backend=None):
        self.name = name
        # self.parent = 'dad'
        self.__tables = {} # this must be a dict of id:table object
        self.__last_assigned_tableid= 0 # to keep track of last assigned tableid

    def __getitem__(self, tablename: str) -> Memory_Table: # subscriptable version of self.get_table()
        tableid = self.get_tableid(tablename)
        if tableid is None:
            tablenames = self.get_tables()
            similar_tables = get_similar_values(tablename, tablenames)
            if len(similar_tables) > 0:
                raise ValueError ('could not find table {}. Did you mean {}?'.format(repr(tablename),' or '.join(similar_tables)))
            else:
                raise ValueError ('could not find table {}.'.format(repr(tablename)))
        else:
            return self.__tables[tableid]       

    
    def __repr__(self):
        rb = {}
        rb['name'] = self.name
        table = []
        for tablename in self.get_tables():
            table.append(tablename)
        rb['tables'] = table
        return json.dumps(rb, indent=2)


    def create_table(self, name: str, 
                     columns: list=None) -> Memory_Table:
        """ create a Memory_Table under bintang object
        name: Name of the table
        columns: List of columns (optional)"""
        tobj = Memory_Table(name, bing=self)
        self.add_table(tobj)
        if columns is not None:
            for column in columns: 
                tobj.add_column(column)
        return tobj        

    
    def drop_table(self, name):
        tableid = self.get_tableid(name)
        if tableid is None:
            tables = self.get_tables()
            similar_tables = get_similar_values(name, tables)
            raise ValueError ('could not find table {}. Did you mean {}?'.format(repr(name),' or '.join(similar_tables)))
        else:
            del self.__tables[tableid]


    def create_path_table(self, name, columns=None):
        tobj = Path_Table(name, bing=self)
        self.add_table(tobj)
        if columns is not None:
            for column in columns: # add column
                tobj.add_column(column)            
        
        
    def get_tableid(self, tablename):
        for id, table in self.__tables.items():
            if tablename.upper() == table.name.upper(): # compare uppercased to ensure case insensitivity
                return id
        return None


    def get_tables(self):
        tables = []
        for table in self.__tables.values():
            tablename = table.name
            tables.append(tablename)    
        return tables


    def get_tablepaths(self):
        pathnames = []
        for table in self.__tables.values():
            pathname = table.path
            pathnames.append(pathname)    
        return pathnames    

    
    def get_columns(self, tablename):
        return self.get_table(tablename).get_columns()
    

    def add_table(self, table: Union[Memory_Table, Path_Table, Sqlite_Table, From_SQL_Table, From_CSV_Table]): 
        tableid = self.get_tableid(table.name)
        if tableid is None:
            tableid = self.__last_assigned_tableid + 1
            self.__tables[tableid] = table
            self.__last_assigned_tableid = self.__last_assigned_tableid + 1 # update it
        elif tableid is not None:
            raise ValueError('Table {} already exists.'.format(table.name))


    def rename_table(self, name, new_tablename):
        # check if name already exists
        if name in self.get_tables():
            for id, table in self.__tables.items():
                if name == table.name:
                    self.__tables[id].name = new_tablename
        else:
            raise ValueError('Tablename {} does not exist.'.format(name))

    
    def get_table(self, name):
        tableid = self.get_tableid(name)
        if tableid is not None:
            return self.__tables[tableid]
        else:
            return None


    def copy_table(self,source_tablename, destination_tablename):
        destination_table = copy.deepcopy(self.get_table(source_tablename))
        destination_table.name = destination_tablename
        self.add_table(destination_table)
        
    
    def drop_column(self,tablename,column):
        self.__tables[tablename].drop_column(column)


    def insert(self,tablename,columns,values):
        self.get_table(tablename).insert(columns,values)
        

    def _get_cells_bycolumns(self,tablename,row,columns):
        _cells = {} # to hold the results cells
        for name in columns:
            columnid = self.__tables[tablename].get_columnid(name)
            # only assign if columnid exists to avoid a KeyError
            if columnid in row.cells:
                _cells[columnid] = row.cells[columnid]
        return _cells

    
    def delete_row(self, tablename, index):
        self.get_table(tablename).delete_row(index)

    
    def delete_rows(self, tablename, indexes):
        self.get_table(tablename).delete_rows(indexes)


    def _print(self):
        tobj = Table('Columns Info')
        row_dict = {}
        for tab in self.get_tables():
            self[tab].set_data_props() # it sets 
            row_dict['table'] = tab
            for col in self[tab].get_columns():
                cobj = self[tab].get_column_object(col)
                #row_dict['id'] = cobj.id
                row_dict['column'] = cobj.name
                row_dict['ordinal_position'] = cobj.ordinal_position
                row_dict['data_type'] = cobj.data_type
                row_dict['column_size'] = cobj.column_size
                row_dict['decimal_digits'] = cobj.decimal_digits
                row_dict['data_props'] = cobj.data_props
                tobj.insert(row_dict)
        tobj.print()


    def print(self):
        tobj = Memory_Table('Tables')
        for tab in self.get_tables():
            row_dict = {}
            row_dict['Bintang'] = self.name
            row_dict['Table'] = tab
            tobj.insert(row_dict)
        tobj.print()
 

    def read_excel(self, wb, sheetnames=None):
        wb_type = get_wb_type_toread(wb)
        # validate sheetnames
        # sheetnames_lced = {x.lower(): x  for x in wb.sheetnames}
        # validate sheetname
        if wb_type == 'openpyxl':
            sheetnames_lced = {x.lower(): x  for x in wb.sheetnames}
        else: # assume xlrd
            sheetnames_lced = {x.lower(): x  for x in wb.sheet_names()}
        if sheetnames is not None: # user specify sheets
            for sheetname in sheetnames:
                if sheetname.lower() not in sheetnames_lced:
                    # get similar sheetname
                    if wb_type == 'openpyxl':
                        similar_sheetnames = get_similar_values(sheetname, wb.sheetnames)
                    else: # assume xlrd
                        similar_sheetnames = get_similar_values(sheetname, wb.sheet_names())
                    raise ValueError ('could not find sheetname {}. Did you mean {}?'.format(repr(sheetname),' or '.join(similar_sheetnames)))
                self.create_table(sheetname)
                self[sheetname].read_excel(wb, sheetname)
        else: # exctract all sheets and create all tables
            for ws in wb:
                sheetname = ws.title
                self.create_table(sheetname)
                self[sheetname].read_excel(wb, sheetname)


    def read_dict(self, dict_obj, tablepaths=None, root='/', path_sep='/'):
        debug = False
        for tprow in iterdict.iterdict(dict_obj, tablepaths=tablepaths, root=root, path_sep=path_sep):
            # if debug:
            #     print("\n---------------------in bintang---------------------")
            #     print(tprow)
            
            for k,v in tprow.cells.items():
                # print(k,'->',v)
                
                # create a table if not created yet
                if tprow.tablepath not in self.get_tables():
                    self.create_path_table(tprow.tablepath)
                
                # upsert this row
                self.get_table(tprow.tablepath).upsert_table_path_row(tprow)
            # if debug:
            #     print("---------------------out bintang--------------------")


    def read_json(self, json_str, tablepaths=None):
        dict_obj = json.loads(json_str) # parse json_str
        self.read_dict(dict_obj, tablepaths) 


    def set_child_tables(self):
        for tab1 in self.get_tablepaths():
            print('table name',tab1)
            # print(self[tab1].get_path_aslist())
            tab1_pathlist = self[tab1].get_path_aslist()
            for tab2 in self.get_tablepaths():
                tab2_pathlist = self[tab2].get_path_aslist()
                if len(tab2_pathlist) - len(tab1_pathlist) == 1:
                    # this is a possible child, not grand child
                    matches = 0
                    for i in range(len(tab1_pathlist)):
                        if tab1_pathlist[i] == tab2_pathlist[i]:
                            matches += 1
                    if matches == len(tab1_pathlist):
                        # this is a child
                        self[tab1].children.append(tab2)      


    def read_sql(self, conn, tablename, sql_str, params=None ):
        self.create_table(tablename)
        cursor = conn.cursor()
        if params is not None:
            cursor.execute(sql_str, params)
        else:
            cursor.execute(sql_str)
        columns = [col[0] for col in cursor.description]
        for row in cursor.fetchall():
            self.insert(tablename, columns, row)


    def _add_lcell(self, lidx, ltable, outrow, out_table, out_lcolumns, rowid):
        for k, v in self[ltable].get_row_asdict(lidx,rowid=rowid).items():
            if out_lcolumns is None:
                # incude all columns
                cell = out_table.make_cell(k,v)
                outrow.add_cell(cell)
            if out_lcolumns is not None:
                # include only the passed columns
                if k in out_lcolumns:
                    cell = out_table.make_cell(k,v)
                    outrow.add_cell(cell)
        return outrow            


    def _add_rcell(self, ridx, rtable, outrow, out_table, out_rcolumns, rcol_resolved, rowid):
        for k, v in rtable.get_row_asdict(ridx,rowid=rowid).items():
            if out_rcolumns is None:
                cell = out_table.make_cell(rcol_resolved[k],v)
                outrow.add_cell(cell)
            if out_rcolumns is not None:
                # include only the passed columns
                if k in out_rcolumns:
                    cell = out_table.make_cell(rcol_resolved[k],v)
                    outrow.add_cell(cell)
        return outrow


    def _resolve_join_columns (self,ltable, rtable_obj, rowid=False):
        # resolve any conflict column by prefixing its tablename
        # notes: conflict column occurs when the same column being used in two joining table.
        rcol_resolved = {}
        if rowid:
            rcol_resolved['rowid_'] = ltable + '_' + 'rowid_'
        lcolumns = self[ltable].get_columns()
        for col in rtable_obj.get_columns():
            if col in lcolumns:
                rcol_resolved[col] = rtable_obj.name + '_' + col
            else:
                rcol_resolved[col] = col
        return rcol_resolved  


    def innerjoin(self
                ,ltable: str #, lkeys
                ,rtable: str | Memory_Table #, rkeys
                ,on: list[tuple] # list of lkey & r key tuple
                ,into: str | None =None
                ,out_lcolumns: list | None = None
                ,out_rcolumns: list | None = None
                ,rowid=False) -> Memory_Table:
        
        rtable_obj = None
        if isinstance(rtable, Memory_Table):
            rtable_obj = rtable
            rtable = rtable.name
        else:
            rtable_obj = self[rtable]
        # validate input eg. column etc
        lkeys = [x[0] for x in on] # generate lkeys from on (1st sequence)
        rkeys = [x[1] for x in on] # generate rkeys from on (2nd sequence)
        lkeys = self[ltable].validate_columns(lkeys)
        #rkeys = self[rtable].validate_columns(rkeys)
        rkeys = rtable_obj.validate_columns(rkeys)
        if out_lcolumns is not None:
            out_lcolumns = self[ltable].validate_columns(out_lcolumns)
        if out_rcolumns is not None:
            # out_rcolumns = self[rtable].validate_columns(out_rcolumns)
            out_rcolumns = rtable_obj.validate_columns(out_rcolumns)

        # resolve columns conflicts
        rcol_resolved = self._resolve_join_columns(ltable, rtable_obj, rowid)
        # create an output table
        out_table = into if into is not None else 'innerjoin'
        out_tobj = Memory_Table(out_table)
        # self.create_table(out_table)
        # out_tobj = self.get_table(out_table)

        # for debuging create merged table to store the matching rowids
        #merged = self.create_table("merged",["lrowid","rrowid"])

        numof_keys = len(on) #(lkeys)
        # loop left table
        # for lidx, lrow in self.iterrows(ltable, columns=lkeys, rowid=rowid):
        for lidx, lrow in self[ltable].iterrows(columns=lkeys, rowid=rowid):    
            # loop right table
            #for ridx, rrow in self.iterrows(rtable_obj, columns=rkeys, rowid=rowid):
            for ridx, rrow in self[rtable].iterrows(columns=rkeys, rowid=rowid):
                matches = 0 # store matches for each rrow
                # compare value for any matching keys, if TRUE then increment matches
                for i in range(numof_keys): 
                    if match(lrow[lkeys[i]], rrow[rkeys[i]]):
                        matches += 1 # incremented!
                if matches == numof_keys: # if fully matched, create the row & add into the output table
                    #debug merged.insert(["lrowid","rrowid"], [lrow["_rowid"], rrow["_rowid"]])
                    #and add the row to output table
                    outrow = out_tobj.make_row()
                    # add cells from left table
                    outrow = self._add_lcell(lidx, ltable, outrow, out_tobj, out_lcolumns, rowid)
                    # add cells from right table
                    outrow = self._add_rcell(ridx, rtable_obj, outrow, out_tobj, out_rcolumns, rcol_resolved, rowid)   
                    out_tobj.add_row(outrow)
        #debug merged.print() 
        return out_tobj
                

    def leftjoin(self,ltable: str
                ,rtable: str
                ,lkeys, rkeys
                ,out_lcolumns=None
                ,out_rcolumns=None
                ,rowid=False):

        # validate input eg. column etc
        lkeys = self[ltable].validate_columns(lkeys)
        rkeys = self[rtable].validate_columns(rkeys)
        if out_lcolumns is not None:
            out_lcolumns = self[ltable].validate_columns(out_lcolumns)
        if out_rcolumns is not None:
            out_rcolumns = self[rtable].validate_columns(out_rcolumns)
        

        # create an output table 
        out_table = ltable + rtable
        self.create_table(out_table)
        out_tobj = self.get_table(out_table)

        # for debuging create merged table to store the matching rowids
        #merged = self.create_table("merged",["lrowid","rrowid"])

        numof_keys = len(lkeys)
        for lidx, lrow in self.iterrows(ltable, columns=lkeys, rowid=rowid):
            outrow = out_tobj.make_row()
            # add cells for ltable
            outrow = self._add_lcell(lidx, ltable, outrow, out_table, out_lcolumns, rowid)
            for ridx, rrow in self.iterrows(rtable, columns=rkeys, rowid=rowid):
                matches = 0 # store matches for each rrow
                # evaluate any matching keys, if so increment matches
                for i in range(numof_keys):
                    if match(lrow[lkeys[i]] == rrow[rkeys[i]]):
                        matches += 1 # increment
                if matches == numof_keys: # if fully matched, create the row & add into the output table
                    #debug merged.insert(["lrowid","rrowid"], [lrow["_rowid"], rrow["_rowid"]])
                    # add cells for rtable
                    outrow = self._add_rcell(ridx, rtable, outrow, out_table, out_rcolumns, ltable, rowid)   
            out_tobj.add_row(outrow)             
        #debug merged.print() 
        return out_tobj
    

    def create_sqlite_table(self, name, conn):
        tobj = Sqlite_Table(name, conn, bing=self) # create a tobj object
        self.add_table(tobj)
        return tobj
    
    
    def create_linked_table(self, name, conn, sql_str=None, params=None ):
        tobj = From_SQL_Table(name, bing=self) # create a tobj object
        tobj.type = 'FROMSQL'
        tobj.conn = conn
        if sql_str is None:
            tobj.sql_str = "SELECT * FROM {}".format(name)
        else:
            tobj.sql_str = sql_str
        tobj.params = params    
        self.add_table(tobj)


    def create_sql_linked_table(self, name, conn, sql_str=None, params=None ):
        """ this is an alias to create_linked_table() """
        tobj = From_SQL_Table(name, bing=self) # create a tobj object
        tobj.conn = conn
        if sql_str is None:
            tobj.sql_str = "SELECT * FROM {}".format(name)
        else:
            tobj.sql_str = sql_str
        tobj.params = params    
        self.add_table(tobj)


    def create_csv_linked_table(self, name, filepath, delimiter=',', quotechar='"', header_row=1):
        tobj = From_CSV_Table(name, filepath, delimiter=delimiter, quotechar=quotechar, header_row=header_row, bing=self) # create a tobj object
        tobj.filepath = filepath
        tobj.delimiter = delimiter
        tobj.quotechar = quotechar
        tobj.header_row = header_row
        self.add_table(tobj) 
  


    