from __future__ import annotations
import os
import typing
from typing import Union, Generator, Iterable, Callable, Mapping, get_origin, get_args, get_type_hints, Any
from collections.abc import Iterable as ABCIterable

from pyonir.pyonir_types import PyonirRequest, PyonirApp, AppCtx

def is_iterable(tp):
    if not isinstance(tp, Iterable): return False
    origin = get_origin(tp) or tp
    return issubclass(origin, ABCIterable)

def is_generator(tp):
    origin = get_origin(tp) or tp
    return issubclass(origin, Generator)

def is_mappable_type(tp) -> bool:
    origin = get_origin(tp)
    args = get_args(tp)

    # Check if the base is a Mapping (like dict) and it has two type arguments
    return (
        origin is not None and
        issubclass(origin, Mapping) and
        len(args) == 2
    )

def is_scalar_type(tp) -> bool:
    origin = get_origin(tp) or tp
    return origin in (int, float, str, bool)

def is_custom_class(t):
    return t.__init__.__annotations__ #isinstance(t, type) and not t.__module__ == "builtins"

def is_optional_type(t):
    if get_origin(t) is not Union: return t
    return [arg for arg in get_args(t) if arg is not type(None)][0]

def is_callable_type(pt) -> bool:
    return get_origin(pt) is Callable or pt.__name__=='callable'

def cls_mapper(file_obj: object, cls: typing.Callable, from_request: PyonirRequest = None):
    from pyonir.core import PyonirRequest, PyonirApp
    param_name, param_type, param_value = ['','','']
    orm_opts = getattr(cls, "__orm_options__", {})
    mapper_keys = getattr(cls, "_mapper", None) or orm_opts.get("mapper", {})
    try:
        if hasattr(cls, '__skip_parsely_deserialization__'):
            return file_obj

        param_type_map = get_type_hints(cls)
        is_generic = cls.__name__ == 'GenericQueryModel'

        if is_scalar_type(cls):
            return cls(file_obj)

        # mapper_keys = getattr(cls, "_mapper", {})
        data = get_attr(file_obj, 'data') or {}
        # _parsely_data_key = '.'.join(['data', get_attr(cls, '_mapper_key') or cls.__name__.lower()])
        # kdata = get_attr(file_obj, _parsely_data_key)
        # if kdata:
        #     data.update(**kdata)

        cls_args = {}
        res = cls() if is_generic else None

        if hasattr(cls, 'from_dict'):  # allows manual mapping of class instance
            return cls.from_dict(file_obj)

        # Build constructor args
        for param_name, param_type in param_type_map.items():
            param_type = is_optional_type(param_type)
            mapper_key = get_attr(mapper_keys, param_name) or param_name
            param_value = get_attr(data, mapper_key) or get_attr(file_obj, mapper_key)
            use_value = False

            if from_request and param_type in (PyonirApp, PyonirRequest):
                from pyonir import Site
                use_value = True
                param_value = Site if param_type == PyonirApp else from_request

            if (param_value == param_type) or param_value is None or param_name[0] == '_' or param_name == 'return':
                continue

            if is_callable_type(param_type):
                cls_args[mapper_key] = param_value
            elif is_iterable(param_type):
                iter_ptype = get_args(param_type)
                is_mapp = is_mappable_type(param_type)
                if is_mapp:
                    ktype, vtype = iter_ptype
                    is_list = is_iterable(vtype)
                    if is_list:
                        vtype = get_args(vtype)[0]
                    cls_args[param_name] = {
                        ktype(key): [cls_mapper(lval, vtype) for lval in value] if is_list else cls_mapper(value, vtype)
                        for key, value in param_value.items()
                    }
                else:
                    cls_args[param_name] = [cls_mapper(itm, iter_ptype[0]) for itm in param_value]
            else:
                is_instance = param_value == param_type
                is_typed = isinstance(param_value, param_type)
                if is_instance and from_request:
                    param_value = cls_mapper(from_request.form, param_type)
                should_spread = isinstance(param_value, dict)
                use_value = use_value or is_typed or is_instance
                v = (
                    param_value if use_value
                    else param_type(**param_value) if should_spread
                    else param_type(param_value)
                )
                cls_args[param_name] = v

        if from_request:
            return cls_args

        if not from_request and param_type_map:
            res = cls(**cls_args)

        # Generic class post-processing
        if is_generic:
            for key in cls.__dict__.keys():
                if key[0] == '_':
                    continue
                value = get_attr(data, key) or get_attr(file_obj, key)
                setattr(res, key, value)

        # ✅ Check ORM options for "frozen"
        if not orm_opts.get("frozen"):
            for key, value in data.items():
                if isinstance(getattr(cls, key, None), property):
                    continue  # skip properties
                if param_type_map.get(key) or key[0] == '_':
                    continue  # skip private or declared attributes
                setattr(res, key, value)

        return res

    except Exception as e:
        print(f"Cls Mapper failed to create a {cls.__name__} instance due to map '{param_name}' parameter wasn't a type of {param_type} : {type(param_value)}")
        raise


def process_contents(path, app_ctx=None, file_model: any = None):
    """Deserializes all files within the contents directory"""
    key = os.path.basename(path)
    res = type(key, (object,), {"__name__": key})() # generic map
    pgs = query_files(path, app_ctx=app_ctx, model=file_model)
    for pg in pgs:
        name = getattr(pg, 'file_name')
        setattr(res, name, pg.map_to_model(None))
    return res


def dict_to_class(data: dict, name: str = 'T', deep: bool = True) -> object:
    """
    Converts a dictionary into a class object with the given name.

    Args:
        data (dict): The dictionary to convert.
        name (str): The name of the class.
        deep (bool): If True, convert all dictionaries recursively.
    Returns:
        object: An instance of the dynamically created class with attributes from the dictionary.
    """
    # Dynamically create a new class
    cls = type(name, (object,), {})

    # Create an instance of the class
    instance = cls()

    # Assign dictionary keys as attributes of the instance
    for key, value in data.items():
        if isinstance(getattr(cls, key, None), property): continue
        if deep and isinstance(value, dict):
            value = dict_to_class(value, key)
        setattr(instance, key, value)

    return instance

def get_attr(rowObj, attrPath=None, default=None, rtn_none=True):
    """
    Resolves nested attribute or dictionary key paths.

    Args:
        obj: the root object
        attr_path: dot-separated string or list for nested access
        default: fallback value if the target is None or missing
        return_none: if True, returns `None` on missing keys/attrs instead of the original object

    Returns:
        The nested value, or `default`, or `obj` based on fallback rules.
    """
    if attrPath == None: return rowObj
    attrPath = attrPath if isinstance(attrPath, list) else attrPath.split('.')
    targetObj = None
    for key in attrPath:
        try:
            if targetObj:
                targetObj = targetObj[key]
            else:
                targetObj = rowObj.get(key)
            pass
        except (KeyError, AttributeError, TypeError) as e:
            if targetObj:
                targetObj = getattr(targetObj, key, None)
            else:
                targetObj = getattr(rowObj, key, None)
            pass
    if targetObj is None and rtn_none:
        return default or None

    return targetObj


def remove_html_tags(text):
    """Remove html tags from a excerpt string"""
    import re
    clean_style = re.sub(r'<style.*?</style>', '', text, flags=re.DOTALL)
    clean_html = re.sub(re.compile('<.*?>'), '', clean_style)
    return clean_html.replace('\n', ' ')


def camel_to_snake(camel_str):
    """Converts camelcase into snake case. Thanks Chat GPT"""
    import re
    snake_str = re.sub(r'(?<!^)(?=[A-Z])', '_', camel_str).lower()
    return snake_str


def deserialize_datestr(datestr, timestr="00:00", fmt="%Y-%m-%d %H:%M:%S %p", zone="US/Eastern", auto_correct=True):
    from datetime import datetime
    import pytz
    if not isinstance(datestr, str): return datestr

    def correct_format(date_str):
        try:
            date_str = date_str.strip().replace('/', '-')
            date_str, _, timestr = date_str.partition(" ")
            timestr = timestr if timestr != "" else '12:13:14 AM'
            has_period = timestr.endswith("M")
            if not has_period:
                timestr += " AM"
            y, m, *d = date_str.split("-")
            d = "".join(d)
            fdate = f"{y}-{int(m):02d}-{d}"
            if int(y) < int(d):
                fdate = f"{d}-{int(y):02d}-{m}"
                print(f"\tIncorrect format on date string {date_str}. it should be {fdate}")
                return fdate
            return f"{fdate} {timestr}"
        except Exception as e:
            return None

    try:
        return pytz.utc.localize(datetime.strptime(datestr, fmt))
    except ValueError as e:
        # return str(e)
        if not auto_correct: return str(e)
        datestr = correct_format(datestr)
        return pytz.utc.localize(datetime.strptime(datestr, fmt)) if datestr else None


def sortBykey(listobj, sort_by_key="", limit="", reverse=True):
    """Sorts list of obj by key"""

    def get_path_object(rowObj, path):
        targetObj = None
        for key in path.split('.'):
            try:
                if targetObj:
                    targetObj = targetObj[key]
                else:
                    targetObj = rowObj[key]
                pass
            except Exception as error:
                raise error
        return targetObj

    try:
        sorted_dict = sorted(getattr(listobj, 'data', listobj), key=lambda obj: get_path_object(obj, sort_by_key),
                             reverse=reverse)
        # sorted_dict = sorted(getattr(listobj,'data', listobj), key = lambda x:x[sort_by_key], reverse=reverse)
        if limit:
            return sorted_dict[:limit]
        return sorted_dict
    except Exception as e:
        return listobj

def parse_query_model_to_object(model_fields: str) -> object:
    if not model_fields: return None
    from pyonir.parser import Parsely
    import importlib
    mapper = {}
    params = {"_mapper": mapper}
    for k in Parsely.default_file_attributes+model_fields.split(','):
        if ':' in k:
            k,_, src = k.partition(':')
            mapper[k] = src
        params[k] = None
    return type('GenericQueryModel', (object,), params)

def query_files(abs_dirpath: str,
                app_ctx: AppCtx = None,
                model: object | str = None,
                name_pattern: str = None,
                exclude_dirs: tuple = None,
                exclude_names: tuple = None,
                force_all: bool = True) -> Generator:
    """Returns a generator of files from a directory path"""
    from pathlib import Path
    from pyonir.parser import Parsely, ParselyMedia, Page
    # results = []
    hidden_file_prefixes = ('.', '_', '<', '>', '(', ')', '$', '!', '._')
    allowed_content_extensions = ('prs', 'md', 'json', 'yaml')
    def get_datatype(filepath) -> object | Parsely | ParselyMedia:
        if model == 'path': return filepath
        pf = Parsely(str(filepath), app_ctx=app_ctx, model=model)
        if pf.is_page and not model: pf.schema = Page
        res = pf.map_to_model(pf.schema)
        return res if pf.schema else pf

    def skip_file(file_path: Path) -> bool:
        """Checks if the file should be skipped based on exclude_dirs and exclude_file"""
        is_private_file = file_path.name.startswith(hidden_file_prefixes)
        is_excluded_file = exclude_names and file_path.name in exclude_names
        is_allowed_file = file_path.suffix[1:] in allowed_content_extensions
        return is_excluded_file or is_private_file or not is_allowed_file

    for path in Path(abs_dirpath).rglob(name_pattern or "*"):
        if skip_file(path): continue
        yield get_datatype(path)
        # results.append(get_datatype(path))
    # return results

# def get_all_files_from_dir(abs_dirpath: str,
#                            app_ctx: list = None,
#                            entry_type: any = None,
#                            include_only: str = None,
#                            exclude_dirs: list[str] = None,
#                            exclude_file: str = None,
#                            force_all: bool = True) -> Generator:
#     """Returns a generator of files from a directory path"""
#     from pyonir.parser import ALLOWED_CONTENT_EXTENSIONS, IGNORE_FILES,Page, Parsely, ParselyMedia
#     from pyonir.core import PyonirBase
#     if abs_dirpath in (exclude_dirs or []): return []
#     if exclude_file is None: exclude_file = []
#     _, _, pages_dirpath, _ = app_ctx
#
#     def get_datatype(parentdir, rel_filepath):
#         filepath = os.path.normpath(os.path.join(parentdir, rel_filepath))
#         if entry_type == 'path':
#             return filepath
#         ismedia = not filepath.endswith(ALLOWED_CONTENT_EXTENSIONS)
#         generic_model = entry_type if entry_type and entry_type.__name__=='GenericQueryModel' else None
#         pf = Parsely(filepath, app_ctx, generic_model)
#         pf.schema = ParselyMedia if ismedia else Page if pf.is_page else generic_model or entry_type
#         return pf.map_to_model(pf.schema)
#         # if ismedia:
#         #     return cls_mapper(pf, ParselyMedia)
#         # return cls_mapper(pf, entry_type or Page ) if entry_type or pf.is_page else pf.map_to_model(None)
#
#
#     def should_skip(parentdir, filename):
#         parentdir = parentdir.replace(pages_dirpath, "").lstrip(os.path.sep)
#         is_include_only_file = (include_only and filename == include_only)
#         if is_include_only_file: return False
#         is_hidden_dir = parentdir.startswith(IGNORE_FILES)
#         is_media_file = filename.endswith(PyonirBase.MEDIA_EXTENSIONS)
#         is_invalid_file = not is_media_file and not filename.endswith(ALLOWED_CONTENT_EXTENSIONS)
#         is_ignored_file = filename in (IGNORE_FILES + tuple(exclude_file))
#         is_private_file = filename.startswith(IGNORE_FILES)
#         if filename!='.routes.md' and force_all and not is_invalid_file: return False
#         if is_hidden_dir or is_invalid_file or is_ignored_file or is_private_file: return True
#
#     for parentdir, subs, files in os.walk(os.path.normpath(abs_dirpath)):
#         folderRoot = parentdir.replace(pages_dirpath, "").lstrip(os.path.sep)
#         subFolderRoot = os.path.basename(folderRoot)
#         skipRoot = (folderRoot in IGNORE_FILES
#                     or folderRoot.startswith(IGNORE_FILES)
#                     or subFolderRoot.startswith(IGNORE_FILES))
#         contains_skipdir = set(folderRoot.split('/')).intersection(exclude_dirs) if exclude_dirs else None
#         skipSubs = subFolderRoot in exclude_dirs if exclude_dirs else 0
#         if skipRoot or skipSubs or contains_skipdir: continue
#
#         for filename in files:
#             # include_only_file = (include_only and filename != include_only)
#             if should_skip(folderRoot, filename): continue
#             yield get_datatype(parentdir, filename)


def delete_file(full_filepath):
    import shutil
    if os.path.isdir(full_filepath):
        shutil.rmtree(full_filepath)
        return True
    elif os.path.isfile(full_filepath):
        os.remove(full_filepath)
        return True
    return False


def create_file(file_abspath: str, data: any = None, is_json: bool = False, mode='w') -> bool:
    """Creates a new file based on provided data
    Args:
        file_abspath: str = path to proposed file
        data: any = contents to write into file
        is_json: bool = strict json file
        mode: str = write mode for file w|w+|a
    Returns:
        bool: The return value if file was created successfully
    """
    def write_file(file_abspath, data, is_json=False, mode='w'):
        import json
        with open(file_abspath, mode, encoding="utf-8") as f:
            if is_json:
                json.dump(data, f, indent=2, sort_keys=True, default=json_serial)
            else:
                f.write(data)

    if not os.path.exists(os.path.dirname(file_abspath)):
        os.makedirs(os.path.dirname(file_abspath))
    try:
        is_json = is_json or file_abspath.endswith('.json')
        write_file(file_abspath, data, is_json=is_json, mode=mode)
        return True
    except Exception as e:
        print(f"Error create_file method: {str(e)}")
        return False


def copy_assets(src: str, dst: str, purge: bool = True):
    """Copies files from a source directory into a destination directory with option to purge destination"""
    import shutil
    from shutil import ignore_patterns
    print(f"{PrntColrs.OKBLUE}Coping `{src}` resource into {dst}{PrntColrs.RESET}")
    try:
        if os.path.exists(dst) and purge:
            shutil.rmtree(dst)
        if os.path.isfile(src):
            shutil.copyfile(src, dst)
        if os.path.isdir(src):
            shutil.copytree(src, dst, ignore=ignore_patterns('__pycache__', '*.pyc', 'tmp*', 'node_modules', '.*'))
    except Exception as e:
        raise



def json_serial(obj):
    """JSON serializer for nested objects not serializable by default jsonify"""
    from datetime import datetime
    from .parser import Parsely
    if isinstance(obj, datetime):
        return obj.isoformat()
    elif isinstance(obj, Generator) or hasattr(obj, 'mapping'):
        return list(obj)
    elif isinstance(obj, Parsely):
        return obj.data
    elif hasattr(obj, 'to_json'):
        return obj.to_json()
    else:
        return obj if not hasattr(obj, '__dict__') else obj.__dict__


def load_modules_from(pkg_dirpath, as_list: bool = False, only_packages:bool = False)-> dict[Any, Any] | list[Any]:
    loaded_mods = {} if not as_list else []
    loaded_funcs = {} if not as_list else []
    if not os.path.exists(pkg_dirpath): return loaded_funcs
    for mod_file in os.listdir(pkg_dirpath):
        name,_, ext = mod_file.partition('.')
        if only_packages:
            pkg_abspath = os.path.join(pkg_dirpath, mod_file, '__init__.py')
            mod, func = get_module(pkg_abspath, name)
        else:
            if ext!='py': continue
            mod_abspath = os.path.join(pkg_dirpath, name.strip())+'.py'
            mod, func = get_module(mod_abspath, name)
        if as_list:
            loaded_funcs.append(func)
        else:
            loaded_mods[name] = mod
            loaded_funcs[name] = func

    return loaded_funcs

def import_module(pkg_path: str, callable_name: str) -> typing.Callable:
    """Imports a module and returns the callable by name"""
    import importlib
    mod_pkg = importlib.import_module(pkg_path)
    importlib.reload(mod_pkg)
    mod = get_attr(mod_pkg, callable_name, None)
    return mod

def get_module(pkg_path: str, callable_name: str) -> tuple[typing.Any, typing.Callable]:
    import sys, importlib
    spec = importlib.util.spec_from_file_location(callable_name, pkg_path)
    if spec is None or spec.loader is None:
        raise ImportError(f"Could not load spec for {callable_name} from {pkg_path}")

    module = importlib.util.module_from_spec(spec)
    # sys.modules[callable_name] = module  # Optional: allows intra-module imports to resolve
    spec.loader.exec_module(module)

    func = get_attr(module, callable_name) or get_attr(module, module.__name__)
    return module, func

# def _get_module(pkg_path: str, callable_name: str) -> tuple[any, typing.Callable]:
#     from importlib import util
#     mod = util.spec_from_file_location(callable_name, pkg_path).loader.load_module(callable_name)
#     func = getattr(mod, callable_name)
#
#     return (mod, func)

def generate_id():
    import uuid
    return str(uuid.uuid1())

def generate_base64_id(value):
    import base64
    return base64.b64encode(value.encode('utf-8'))

def load_env(path=".env") -> object:
    from collections import defaultdict
    if not os.path.exists(path): return {}
    def set_nested(d, keys, value):
        """Helper to set value in nested dictionary using dot-separated keys."""
        for key in keys[:-1]:
            d = d.setdefault(key, {})
        d[keys[-1]] = value

    env_data = defaultdict(dict)

    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            if "=" not in line:
                continue

            key, value = line.split("=", 1)
            key = key.strip()
            value = value.strip()

            # Set in os.environ (flat)
            os.environ.setdefault(key, value)

            # Set in nested dict (structured)
            keys = key.split(".")
            set_nested(env_data, keys, value)

    return dict_to_class(env_data, 'env')


class PrntColrs:
    RESET = '\033[0m'
    HEADER = '\033[95m'
    OKBLUE = '\033[94m'
    OKCYAN = '\033[96m'
    OKGREEN = '\x1b[0;92;49m'
    WARNING = '\033[93m'
    FAIL = '\033[91m'
    ENDC = '\033[0m'
    BOLD = '\033[1m'
    UNDERLINE = '\033[4m'
