from qgis.core import QgsApplication,Qgis, QgsMessageLog, QgsProject
from qgis.PyQt.QtCore import QSettings
from qgis.PyQt import QtCore
from qgis.utils import iface

from .translations import *

from datetime import datetime 
from scipy.interpolate import interp1d
import numpy as np
import shutil
import time
import os
import ast
import json
import re
import ntpath

import ast
import operator as op

operators = {
    ast.Add: op.add,
    ast.Sub: op.sub,
    ast.Mult: op.mul,
    ast.Div: op.truediv,
    ast.Pow: op.pow,
    ast.USub: op.neg,
}

safe_functions = {
    "str": str,
    "int": int,
    "float": float,
    "round": round,
}

def safe_eval(expr):

    def _eval(node):

        # constants
        if isinstance(node, ast.Constant):
            return node.value

        # binary operations
        elif isinstance(node, ast.BinOp):
            return operators[type(node.op)](
                _eval(node.left),
                _eval(node.right)
            )

        # unary operations
        elif isinstance(node, ast.UnaryOp):
            return operators[type(node.op)](
                _eval(node.operand)
            )

        # function calls
        elif isinstance(node, ast.Call):

            if not isinstance(node.func, ast.Name):
                raise TypeError("Only simple functions allowed")

            func_name = node.func.id

            if func_name not in safe_functions:
                raise TypeError(f"Function not allowed: {func_name}")

            args = [_eval(arg) for arg in node.args]

            return safe_functions[func_name](*args)

        # lists
        elif isinstance(node, ast.List):
            return [_eval(e) for e in node.elts]

        # tuples
        elif isinstance(node, ast.Tuple):
            return tuple(_eval(e) for e in node.elts)

        # dictionaries
        elif isinstance(node, ast.Dict):
            return {
                _eval(k): _eval(v)
                for k, v in zip(node.keys, node.values)
            }

        else:
            raise TypeError(f"Unsupported node: {type(node).__name__}")

    parsed = ast.parse(expr, mode='eval')
    return _eval(parsed.body)
    
def sanitize_pg_identifier(name: str) -> str:
    """
    Macht PostgreSQL-Identifier sicher:
    - unkritische Namen bleiben unverändert
    - alles mit Großbuchstaben / Sonderzeichen wird gequotet
    """

    if name is None:
        return None

    # erlaubt: a-z, 0-9, _
    if re.fullmatch(r"[a-z0-9_]+", name):
        return name

    # sonst: doppelte Quotes setzen (PostgreSQL-Style)
    name = name.replace('"', '""')  # escape existing quotes
    return f'"{name}"'
    
def get_districts_plugin_dir():
    """
    Returns the path to the QGIS districts directory
    for the active user profile.
    """
    settings_dir = QgsApplication.qgisSettingsDirPath()
    return os.path.join(settings_dir, "python", "plugins","districts")
    
def checkToolPaths(config):
    pathCheck={'pathDistricts':False,'pathProjects':False}
    if os.path.exists(os.path.join(config['pathDistricts'],'bin','districts.exe')):
        pathCheck['pathDistricts']=True
    else:
        iface.messageBar().pushMessage("Info", "Please update your IDA Districts path!", level=Qgis.Info)
    if os.path.exists(config['pathProjects']):
        pathCheck['pathProjects']=True
    else:
        iface.messageBar().pushMessage("Info", "Please update your project path!", level=Qgis.Info)

    return pathCheck
    
def standardizePath(path: str, create_if_not_exists: bool = False,trailingBackSlash: bool = True) -> str:
    if not path:
        return ""

    # Normalize path (Windows-style)
    normalized = ntpath.normpath(path)

    # Convert to backslashes
    standardized = normalized.replace("/", "\\")

    # Ensure trailing backslash
    if trailingBackSlash:
        if not standardized.endswith("\\"):
            standardized += "\\"
    else: 
        if standardized.endswith("\\"):
            standardized=standardized[:-1]
            
    # Optionally create directory
    if create_if_not_exists:
        os.makedirs(standardized, exist_ok=True)
        
    return standardized
    
def getIDAVersion(config):
    return config['ida_version']
    
def getIDADistrictsVersion(config):
    version=config['ida_districts_version'].split('.')
    return version[0]+'.'+''.join(version[1:4])
    
def getDistrictsModelerVersion(config):
    return config['districts_modeler_version']
    
def getQtVersion():
    return QtCore.QT_VERSION >= 0x060000
    
def getAuthNames():
    """return a dictionary of authenication ids"""
    auth_dict={}
    auth_mgr = QgsApplication.authManager()
    auth_configs = auth_mgr.availableAuthMethodConfigs()
    for auth_id, cfg in auth_configs.items():
        auth_dict[cfg.name()] = auth_id
        
    return auth_dict

def write_plugin_settings(config):
    config['ida_version']='5.21801'
    config['ida_districts_version']='1.0.0.0'
    config['districts_modeler_version']='1.0.0.0'
    settings = QSettings()        
    settings.beginGroup("districts")
    for key,value in config.items():
        settings.setValue(key, value)
    settings.endGroup()
        
def load_plugin_settings():
    settings = QSettings()
    settings.beginGroup("districts")

    config = {
        #tool settings
        "pathProjects": settings.value("pathProjects","C:\\projects\\"),
        "pathDistricts": settings.value("pathDistricts","C:\\Program Files (x86)\\IDA Districts\\"),
        "pathPostgresql": settings.value("pathPostgresql","C:\\Program Files\\PostgreSQL\\18\\"),
        "districts_api_delay": settings.value("districts_api_delay","20"),
        "debug": settings.value("debug",False, type=bool),
        "autosave": settings.value("autosave", True, type=bool),
        "autosave_dt": settings.value("autosave_dt", "120"),
        "exportInvokedFeatures": settings.value("exportInvokedFeatures", False, type=bool),
        "exportPrn": settings.value("exportPrn", False, type=bool),
        "exportDbResults": settings.value("exportDbResults", False, type=bool),
        #db
        "host": settings.value("host", "localhost"),
        "port": settings.value("port", "5432"),
        "autosave": settings.value("autosave", False, type=bool),
        "auth_id": settings.value("auth_id", ""),
        #project
        "projectName": settings.value("projectName",""),
        "versionName": settings.value("versionName",""),        
        "lastVersionName": settings.value("lastVersionName",""),      
        #versions
        "ida_version": settings.value("ida_version",""),
        "ida_districts_version": settings.value("ida_districts_version",""),
        "districts_modeler_version": settings.value("districts_modeler_version","")
    }

    settings.endGroup()
    #print(config)
    return config
    
def rmtree_long_path(dir):
    if os.path.exists(dir) and os.path.isdir(dir):
        if os.name == 'nt':
            dir='\\\\?\\'+dir
        shutil.rmtree(dir)
        
def copy_tree_filter_extensions_and_folders2(src, dst, signals=None,exclude_extensions=None, exclude_folders=None):
    # Set default values if no extensions or folders are provided
    if exclude_extensions is None:
        exclude_extensions = []
    if exclude_folders is None:
        exclude_folders = []
    if exclude_folders is None:
        signals = False

    # Ensure the extensions and folder names are in lowercase for case-insensitive comparison
    exclude_extensions = [ext.lower() for ext in exclude_extensions]
    exclude_folders = [folder.lower() for folder in exclude_folders]

    # Walk through the source directory
    for root, dirs, files in os.walk(src):
        # Filter out files with the specified extensions
        files_to_copy = [f for f in files if not any(f.lower().endswith(ext) for ext in exclude_extensions)]
        
        # Copy each file to the destination, skipping excluded extensions
        for file in files_to_copy:
            full_file_path = os.path.join(root, file)
            relative_path = os.path.relpath(full_file_path, src)
            destination_file_path = os.path.join(dst, relative_path)
            try:
                if os.name == 'nt' and '\\\\?\\' not in destination_file_path:  # Windows OS
                    destination_file_path = '\\\\?\\' + os.path.abspath(destination_file_path)
                if os.name == 'nt' and '\\\\?\\' not in full_file_path:  # Windows OS
                    full_file_path = '\\\\?\\' + os.path.abspath(full_file_path)
                # Make sure the destination directory exists
                os.makedirs(os.path.dirname(destination_file_path), exist_ok=True)
                
                # Copy the file to the destination
                shutil.copy2(full_file_path, destination_file_path)
            except:
                if signals:
                    signals.error.emit("Failed to copy file (file path probably to long):"+destination_file_path)
        
        # Prevent os.walk from traversing excluded directories
        for dir_name in dirs[:]:
            if dir_name.lower() in exclude_folders:
                dirs.remove(dir_name)  # Remove directory from traversal list

def copy_tree_filter_extensions_and_folders(src, dst, signals=None, exclude_extensions=None, exclude_folders=None):
    # Set defaults
    if exclude_extensions is None:
        exclude_extensions = []
    if exclude_folders is None:
        exclude_folders = []
    if signals is None:
        signals = False

    # Normalize to lowercase
    exclude_extensions = [ext.lower() for ext in exclude_extensions]
    exclude_folders = [folder.lower() for folder in exclude_folders]

    for root, dirs, files in os.walk(src):
        # Remove excluded directories from traversal
        dirs[:] = [d for d in dirs if d.lower() not in exclude_folders]

        # Compute destination directory path
        relative_dir = os.path.relpath(root, src)
        destination_dir = os.path.join(dst, relative_dir)

        try:
            # Ensure directory exists (this is what enables empty folder copying)
            os.makedirs(destination_dir, exist_ok=True)
        except Exception as e:
            if signals:
                signals.error.emit(f"Failed to create directory: {destination_dir} | {e}")
            continue

        # Filter files by extension
        files_to_copy = [
            f for f in files
            if not any(f.lower().endswith(ext) for ext in exclude_extensions)
        ]

        for file in files_to_copy:
            full_file_path = os.path.join(root, file)
            destination_file_path = os.path.join(destination_dir, file)

            try:
                # Windows long path handling
                if os.name == 'nt':
                    if not destination_file_path.startswith('\\\\?\\'):
                        destination_file_path = '\\\\?\\' + os.path.abspath(destination_file_path)
                    if not full_file_path.startswith('\\\\?\\'):
                        full_file_path = '\\\\?\\' + os.path.abspath(full_file_path)

                shutil.copy2(full_file_path, destination_file_path)

            except Exception as e:
                if signals:
                    signals.error.emit(f"Failed to copy file: {destination_file_path} | {e}")
                    
def flatten(nested_list):
    return [item for sublist in nested_list for item in sublist]
    
def flatten_nestedList(nested_list):
    flat_list = []
    for item in nested_list:
        if isinstance(item, list):
            flat_list.extend(flatten_nestedList(item))  # Recursively flatten the sublist
        else:
            flat_list.append(item)
    return flat_list 

def isNumber(input):
    try:
        if float(input) or float(input)==0.0:
            return True
        else:
            return False
    except:
        return False
        
def interpolateTimeData(dt,file_data):
    start_hour=file_data[0,0]
    start_hour=start_hour-start_hour%(dt/3600)
    end_hour=file_data[-1,0]
    end_hour=end_hour-end_hour%(dt/3600)+dt/3600

    time=np.arange(start_hour, end_hour+dt/3600, dt/3600)

    # apply the interpolation to each column
    f = interp1d(file_data[:,0], file_data[:,1:], axis=0, fill_value="extrapolate")

    # get final result
    file_data=np.column_stack((time,f(time)))
    return file_data
    
def getDateTime(y=2024,m=1,d=1,h=0,min=0,s=0):
    return datetime(y, m, d, h, min,s)
    
def getDatetimeFromString(date_string):
    time_split=date_string.split()
    return getDateTime(int(time_split[0].split('-')[0]), int(time_split[0].split('-')[1]), int(time_split[0].split('-')[2]), int(time_split[1].split(':')[0]), int(time_split[1].split(':')[1]),int(time_split[1].split(':')[2]))

def getNTPTime(year,month,day,hour,minute,second):
    diff = datetime(year, month, day, hour, minute, second) - datetime(1900, 1, 1, 0, 0, 0)
    timestamp = diff.days*24*60*60+diff.seconds
    return timestamp

def getNTPTimeFromString(date_string):
    return getNTPTime(int(date_string.split('-')[0]),int(date_string.split('-')[1]),int(date_string.split()[0].split('-')[2]),int(date_string.split()[1].split(':')[0]),int(date_string.split(':')[1]),int(date_string.split(':')[2]))
    
def getNTPTimeFromDatetime(datetime):
    return getNTPTime(datetime.year,datetime.month,datetime.day,datetime.hour,datetime.minute,datetime.second)
                                    
def getTimeFromNTP(ntp):
    t = time.ctime(ntp-2208992400)
    return datetime.strptime(t, '%a %b %d %H:%M:%S %Y')

class alt_count:
    def __init__(self, start=0, step=1):
        self.current = start - step
        self.step = step

    def __next__(self):
        self.current = self.current + self.step
        return self.current
        
def listToBracketsString(A):
    r = ''
    i=0
    for item in A:
        if isinstance(item,list): 
            r+= ('(' if i==0 else ' ')+listToBracketsString(item)+(')' if i==len(A)-1 else '')
        else: 
            r+=('(' if i==0 else ' ') +item+(')' if i==len(A)-1 else '')
        i+=1
    return r
                
def isInt(str):
    try:
        int(str)
        return True
    except:
        return False

def isFloat(str):
    try:
        float(str)
        return True
    except:
        return False 

def strToDict(s: str) -> dict:
    """
    Converts a string representation of a dictionary into an actual Python dict.
    Handles quotes, booleans, numbers, and nested structures gracefully.
    Example: "{'srid': '25832'}" -> {'srid': '25832'}
    """
    if not isinstance(s, str) or not s.strip():
        return {}

    s = s.strip()

    # CRITICAL FIX: Replace single backslashes with double backslashes
    # to prevent "invalid escape sequence" errors in ast.literal_eval and json.loads.
    # This ensures backslashes are treated as literal characters (e.g., in file paths).
    s_safe = s.replace('\\', '\\\\')

    # First try: use ast.literal_eval (safe eval for Python literals)
    try:
        result = ast.literal_eval(s_safe) # Use the safe string
        if isinstance(result, dict):
            return result
        else:
            return {}
    except Exception:
        pass

    # Second try: clean up and parse manually if input is malformed
    try:
        s_clean = s_safe.replace("'", '"')  # convert single to double quotes for JSON
        result = json.loads(s_clean)
        if isinstance(result, dict):
            return result
    except Exception:
        pass

    # Fallback: manual parsing for simple key:value pairs (uses original s, should be fine for simple pairs)
    try:
        s_inner = s.strip("{} ")
        items = [i.strip() for i in s_inner.split(",") if ":" in i]
        d = {}
        for item in items:
            key, val = [x.strip() for x in item.split(":", 1)]
            key = key.strip("'\"")
            val = val.strip("'\"")

            # Convert common data types
            if val.lower() == "true":
                val = True
            elif val.lower() == "false":
                val = False
            elif val.lower() == "none" or val == "":
                val = None
            else:
                try:
                    # Try to convert to number if possible
                    if "." in val:
                        val = float(val)
                    else:
                        val = int(val)
                except ValueError:
                    pass

            d[key] = val
        return d
    except Exception:
        return {}


    
def checkString(my_string):
    """Check if String contains upper cases, white spaces or spacial characters """

    #checking empty string
    if len(my_string)==0:
        iface.messageBar().pushMessage("Error", "Please enter a name!", level=Qgis.Critical)
        return False

    #start with a character
    if not my_string[0].isalpha():
        iface.messageBar().pushMessage("Error", "Please start with an alphabetic character!", level=Qgis.Critical)
        return False
        
    ok=True
    for char in my_string:
        # checking for uppercase character and flagging
        if char.isupper():
            ok = False
            break
      
    #checking for white spaces
    if ' ' in my_string:
        ok = False
        
    #checking for spacial characters
    special_characters="!@#$%^&*()-+?=,<>/\""
    if any(c in special_characters for c in my_string):   
        ok = False
        
    if not ok:
        iface.messageBar().pushMessage("Info", "Please don`t use upper cases, white spaces or special characters in your name!", level=Qgis.Info)
    
    return ok
    
def refreshMap():
    """ Refresh the canvas map"""
    iface.mapCanvas().refreshAllLayers()
    
def zoomToLayer(layerName):
    """Zoom extends to layer with layerName"""
    vLayer = QgsProject.instance().mapLayersByName(tr('@default',layerName))[0]
    canvas = iface.mapCanvas()
    extent = vLayer.extent()
    canvas.setExtent(extent)
    refreshMap()
    
def checkStringSize(str,limit):
    """check if the string size is within the limit """
    if len(str)<limit:
        return True
    else:
        return False
        
def getTemplateNameById(type_id):
    typeName=""
    if type_id==1:
        typeName="customer_templates"
    elif type_id==2:
        typeName="energy_plant_templates"
    return typeName

def getTypeNameById(type_id):
    typeName=""
    if type_id==1:
        typeName="customers"
    elif type_id==2:
        typeName="energy_plants"
    return typeName
    
def getTypeIdByName(type_name):
    if type_name=='customer' or type_name=='Customer':
        type_id='1'
    elif type_name=='energy_plant' or type_name=='Energy plant' or type_name=='Energy_plant':
        type_id='2'
    else:
        type_id='3'
    return type_id
    
def returnVarName(measure):
    varName=""
    if measure==1:
        varName="|T_var|"
    elif measure==2:
        varName="|P_var|"
    elif measure==3:
        varName="|M_var|"
    elif measure==4:
        varName="P"
    return varName
    
def getMacroTypeName(type):
    type_name=""
    if type==1:
        type_name='Customer'
    elif type==2:
        type_name='Energy_plant'
    elif type==3:
        type_name='Supervisory_control'
    elif type==4:
        type_name='Results-macro'
    return type_name
    
def checkListNumbers(list_numb):
    """check if entries in list are numbers"""
    for numb in list_numb:
        if not isFloat(numb):
            return False
    return True
  
def is_number(s):
    try:
        float(s)
        return True
    except:
        return False
        
def is_date(date):
    try:
        time_split=date.split()
        time={'year':int(time_split[0].split('-')[0]),'month':int(time_split[0].split('-')[1]),'day':int(time_split[0].split('-')[2]),'hour':int(time_split[1].split(':')[0]),'minute':int(time_split[1].split(':')[1]),'second':int(time_split[1].split(':')[2])}
        if len(str(time['year']))==4 and len(str(time['month'])) in [1,2] and len(str(time['day'])) in [1,2] and len(str(time['hour'])) in [1,2] and len(str(time['minute'])) in [1,2] and len(str(time['second']))in [1,2]:
            return True
        else:
            return False
    except:
        return False
    
            
    
    