# filters.py — ScanConfig, canonical-kwargs normalization, the allow/exclude
# predicate, and file/dir collection. Local filesystem only.
import os
import sys
import glob
import fnmatch
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, List, Optional, Set, Union

from .constants import (
    CANONICAL_MAP,
    DEFAULT_ALLOWED_EXCLUDE_MAP,
    DEFAULT_CANONICAL_MAP,
)

# -------------------------
# Small utilities
# -------------------------
def make_list(obj, commaparse=True) -> list:
    """
    Converts the input object to a list. If the object is already a list, it is
    returned as is. Comma-separated strings split into their parts unless
    commaparse is False.
    """
    if isinstance(obj, str):
        if ',' in obj and commaparse == True:
            obj = obj.split(',')
    if isinstance(obj, set) or isinstance(obj, tuple):
        return list(obj)
    if isinstance(obj, list):
        return obj
    return [obj]

def combine_params(*values, typ=None):
    nu_values = None
    for value in values:
        if value is not None:
            typ = typ or type(value)
            if nu_values is None:
                nu_values = typ()
            if typ is set:
                nu_values = nu_values | typ(value)
            if typ is list:
                nu_values += typ(value)
    return nu_values

def get_initial_caller() -> Optional[str]:
    """
    Return the TRUE original caller: the entrypoint script that launched the program.
    """
    main_mod = sys.modules.get('__main__')
    # interactive environments (REPL) may have no __file__
    if not main_mod or not hasattr(main_mod, '__file__'):
        return None
    return os.path.realpath(main_mod.__file__)

def get_initial_caller_dir() -> Optional[str]:
    """
    Return the directory of the TRUE original entrypoint script.
    """
    caller = get_initial_caller()
    return os.path.dirname(caller) if caller else None

# -------------------------
# Canonical kwargs
# -------------------------
def get_safe_kwargs(canonical_map, **kwargs):
    # Lowercase all keys for safety
    canonical_map = canonical_map or CANONICAL_MAP
    norm_kwargs = {k.lower(): v for k, v in kwargs.items() if v is not None}

    # Inverse lookup: alias → canonical key
    alias_lookup = {
        alias: canon
        for canon, aliases in canonical_map.items()
        if aliases
        for alias in aliases
    }

    # Preserve correctly named keys
    safe_kwargs = {k: v for k, v in norm_kwargs.items() if k in canonical_map}

    for k, v in norm_kwargs.items():
        if k in alias_lookup:
            canonical_key = alias_lookup[k]
            prev = safe_kwargs.get(canonical_key)
            if prev is None:
                safe_kwargs[canonical_key] = v
            else:
                # merge intelligently if both exist
                if isinstance(prev, (set, list)) and isinstance(v, (set, list)):
                    safe_kwargs[canonical_key] = list(set(prev) | set(v))
                else:
                    safe_kwargs[canonical_key] = v  # overwrite for non-iterables

    # fill defaults if missing
    for canon in canonical_map:
        safe_kwargs.setdefault(canon, None)

    return safe_kwargs

def create_canonical_map(*args, canonical_map=None):
    keys = [arg for arg in args if arg]
    if not keys:
        return CANONICAL_MAP
    canonical_map = canonical_map or CANONICAL_MAP
    return {key: canonical_map.get(key) for key in keys}

def get_safe_canonical_kwargs(*args, canonical_map=None, **kwargs):
    canonical_map = canonical_map or create_canonical_map(*args)
    return get_safe_kwargs(canonical_map=canonical_map, **kwargs)

def get_dir_filter_kwargs(**kwargs):
    canonical_map = create_canonical_map("directories")
    return get_safe_kwargs(canonical_map=canonical_map, **kwargs)

def get_file_filter_kwargs(**kwargs):
    """
    Normalize arbitrary keyword arguments for file scanning configuration.

    Examples:
      - 'excluded_ext' or 'unallowed_exts' → 'exclude_exts'
      - 'include_dirs' or 'allow_dir' → 'allowed_dirs'
      - 'excluded_patterns' → 'exclude_patterns'
      - 'allowed_type' or 'include_types' → 'allowed_types'
    """
    canonical_keys = ["allowed_exts", "exclude_exts", "allowed_types", "exclude_types",
                      "allowed_dirs", "exclude_dirs", "allowed_patterns", "exclude_patterns"]
    return get_safe_canonical_kwargs(*canonical_keys, **kwargs)

# -------------------------
# Input normalization
# -------------------------
def normalize_listlike(value, typ=list, sep=','):
    """Normalize comma-separated or iterable values into the desired type."""
    if value in [True, None, False]:
        return value
    if isinstance(value, str):
        value = [v.strip() for v in value.split(sep) if v.strip()]
    return typ(value)

def ensure_exts(exts):
    if exts in [True, None, False]:
        return exts
    cleaned = set()
    for ext in normalize_listlike(exts, list):
        ext = ext.strip().lower()
        ext = ext.lstrip(".")      # remove ALL leading dots
        cleaned.add("." + ext)     # add exactly one
    return cleaned

def ensure_patterns(patterns):
    """Normalize pattern list and ensure they are valid globs."""
    if patterns in [True, None, False]:
        return patterns
    patterns = normalize_listlike(patterns, list)
    out = []
    for pattern in patterns:
        if not pattern:
            continue
        if '*' not in pattern and '?' not in pattern:
            # Implicitly make it a prefix match
            if pattern.startswith('.') or pattern.startswith('~'):
                pattern = f"*{pattern}"
            else:
                pattern = f"{pattern}*"
        out.append(pattern)
    return out

def ensure_directories(*args, **kwargs):
    directories = []
    for arg in args:
        if arg is None:
            continue
        arg_str = str(arg)
        if os.path.isdir(arg_str):
            directories.append(arg_str)
        elif os.path.isfile(arg_str):
            directories.append(os.path.dirname(arg_str))
    if not directories:
        safe_directories = get_dir_filter_kwargs(**kwargs)
        safe_dirs = safe_directories.get('directories')
        if safe_dirs is None:
            safe_dirs = get_initial_caller_dir()
        directories += make_list(safe_dirs, commaparse=False)
    return list(set(r for r in directories if r))

# -------------------------
# Config dataclass
# -------------------------
@dataclass
class ScanConfig:
    allowed_exts: Set[str]
    exclude_exts: Set[str]
    allowed_types: Set[str]
    exclude_types: Set[str]
    allowed_dirs: List[str] = field(default_factory=list)
    exclude_dirs: List[str] = field(default_factory=list)
    allowed_patterns: List[str] = field(default_factory=list)
    exclude_patterns: List[str] = field(default_factory=list)

# -------------------------
# Default derivation logic
# -------------------------
def _get_default_modular(value, default, add=None, typ=set):
    """Merge a user-supplied value with the default for one filter key.

    None or True -> the default set
    False        -> filter disabled
    add          -> user value merged WITH the default
    otherwise    -> the user value alone
    """
    if value is None or value is True:
        return default
    if value is False:
        return False
    if add:
        return combine_params(typ(value), default, typ=typ)
    return typ(value)

def derive_all_defaults(**kwargs):
    kwargs = get_safe_canonical_kwargs(**kwargs)
    add = kwargs.get("add", False)
    nu_defaults = {}
    for key, values in DEFAULT_CANONICAL_MAP.items():
        default = values.get("default")
        typ = values.get("type")
        key_value = kwargs.get(key)
        if key in DEFAULT_ALLOWED_EXCLUDE_MAP:
            if key.endswith('exts'):
                input_value = ensure_exts(key_value)
            elif key.endswith('patterns'):
                input_value = ensure_patterns(key_value)
            else:
                input_value = normalize_listlike(key_value, typ)
            nu_defaults[key] = _get_default_modular(input_value, default, add, typ)
        else:
            value = default if key_value is None else key_value
            if typ == list:
                value = make_list(value, commaparse=False)
            elif typ == bool:
                value = bool(value)
            nu_defaults[key] = value
    return nu_defaults

def derive_file_defaults(**kwargs):
    kwargs = derive_all_defaults(**kwargs)
    add = kwargs.get("add", False)
    nu_defaults = {}
    for key, values in DEFAULT_ALLOWED_EXCLUDE_MAP.items():
        default = values.get("default")
        typ = values.get("type")
        key_value = kwargs.get(key)
        nu_defaults[key] = _get_default_modular(key_value, default, add, typ)
    return nu_defaults

def define_defaults(**kwargs):
    defaults = derive_file_defaults(**kwargs)
    return ScanConfig(**defaults)

# -------------------------
# Allow/exclude predicate
# -------------------------
def make_allowed_predicate(cfg: ScanConfig = None, **kwargs) -> Callable[[str], bool]:
    """
    Build and return a function `allowed(path)` that evaluates the given ScanConfig.
    Directory and type filters match exact path segments to avoid accidental
    matches inside unrelated names (e.g., 'abstract' matching 'archive').
    """
    cfg = cfg or define_defaults(**kwargs)

    def allowed(path: str) -> bool:
        p = Path(path)
        name = p.name.lower()
        path_str = str(p).lower()
        parts = path_str.split(os.sep)

        # --------------------
        # A) directory filters
        # --------------------
        # Excluded dirs: reject if any directory in the path matches exactly
        if cfg.exclude_dirs:
            if any(d.lower() in parts for d in cfg.exclude_dirs):
                return False

        # Allowed dirs: require at least one match (unless "*")
        if cfg.allowed_dirs and "*" not in cfg.allowed_dirs:
            if not any(d.lower() in parts for d in cfg.allowed_dirs):
                return False

        # --------------------
        # B) pattern filters
        # --------------------
        if cfg.allowed_patterns and "*" not in cfg.allowed_patterns:
            if not any(fnmatch.fnmatch(name, pat.lower()) for pat in cfg.allowed_patterns):
                return False

        if cfg.exclude_patterns:
            if any(fnmatch.fnmatch(name, pat.lower()) for pat in cfg.exclude_patterns):
                return False

        # --------------------
        # C) extension filters
        # --------------------
        try:
            _abs_is_file = p.is_file()
        except OSError:
            # unstattable path (e.g. /proc/1/cwd, EACCES) -> skip, don't crash the scan
            return False
        if _abs_is_file:
            ext = p.suffix.lower()
            if cfg.allowed_exts and ext not in cfg.allowed_exts:
                return False
            if cfg.exclude_exts and ext in cfg.exclude_exts:
                return False

        # --------------------
        # D) type filters (exact path-segment matching)
        # --------------------
        if cfg.allowed_types and "*" not in cfg.allowed_types:
            if not any(t.lower() in parts for t in cfg.allowed_types):
                return False

        if cfg.exclude_types and "*" not in cfg.exclude_types:
            if any(t.lower() in parts for t in cfg.exclude_types):
                return False

        return True

    return allowed

def get_allowed_predicate(allowed=None, cfg=None, **kwargs):
    if allowed is False:
        return lambda *args: True
    if allowed is True:
        allowed = None
    return allowed or make_allowed_predicate(cfg=cfg, **kwargs)

def filter_allowed_items(items, cfg=None, **kwargs):
    """
    Apply ScanConfig allow/exclude rules to a flat list of file or directory paths.
    No recursion. No globs. Just pure deterministic filtering.
    """
    allowed = make_allowed_predicate(cfg=cfg, **kwargs)
    return [item for item in items if allowed(item)]

# -------------------------
# File/dir collection
# -------------------------
def get_globs(items, recursive: bool = True, allowed=None, cfg=None, **kwargs):
    glob_paths = []
    allowed = get_allowed_predicate(allowed=allowed, cfg=cfg, **kwargs)
    items = [item for item in make_list(items, commaparse=False) if item]
    for item in items:
        pattern = os.path.join(item, "**/*")  # include all files recursively
        nuItems = glob.glob(pattern, recursive=recursive)
        if allowed:
            nuItems = [nuItem for nuItem in nuItems if nuItem and allowed(nuItem)]
        glob_paths += nuItems
    return glob_paths

def get_allowed_files(items, allowed=True, cfg=None, **kwargs):
    allowed = get_allowed_predicate(allowed=allowed, cfg=cfg, **kwargs)
    return [item for item in items if item and os.path.isfile(item) and allowed(item)]

def get_allowed_dirs(items, allowed=False, cfg=None, **kwargs):
    allowed = get_allowed_predicate(allowed=allowed, cfg=cfg, **kwargs)
    return [item for item in items if item and os.path.isdir(item) and allowed(item)]

def get_filtered_files(items, allowed=None, files=[], cfg=None, **kwargs):
    allowed = get_allowed_predicate(allowed=allowed, cfg=cfg, **kwargs)
    glob_paths = get_globs(items, allowed=allowed, cfg=cfg, **kwargs)
    return [glob_path for glob_path in glob_paths
            if glob_path and os.path.isfile(glob_path) and glob_path not in files and allowed(glob_path)]

def get_filtered_dirs(items, allowed=None, dirs=[], cfg=None, **kwargs):
    allowed = get_allowed_predicate(allowed=allowed, cfg=cfg, **kwargs)
    glob_paths = get_globs(items, allowed=allowed, cfg=cfg, **kwargs)
    return [glob_path for glob_path in glob_paths
            if glob_path and os.path.isdir(glob_path) and glob_path not in dirs and allowed(glob_path)]

def get_all_allowed_files(items, allowed=None, cfg=None, **kwargs):
    dirs = get_all_allowed_dirs(items, allowed=allowed, cfg=cfg, **kwargs)
    files = get_allowed_files(items, allowed=allowed, cfg=cfg, **kwargs)
    for directory in dirs:
        files += get_filtered_files(directory, allowed=allowed, files=files, cfg=cfg, **kwargs)
    return files

def get_all_allowed_dirs(items, allowed=None, cfg=None, **kwargs):
    allowed = get_allowed_predicate(allowed=allowed, cfg=cfg, **kwargs)
    dirs = get_allowed_dirs(items, allowed=allowed, cfg=cfg, **kwargs)
    nu_dirs = []
    for directory in dirs:
        nu_dirs += get_filtered_dirs(directory, allowed=allowed, dirs=nu_dirs, cfg=cfg, **kwargs)
    return nu_dirs

def get_files_and_dirs(*args, directory=None, directories=None, cfg=None,
                       allowed=None, recursive=True, include_files=True, **kwargs):
    """
    Return ``(dirs, files)`` collected from one or more directories.

    Roots come from positional args and/or the directory/directories kwargs
    (defaulting to the current working directory). Results are filtered through
    make_allowed_predicate unless allowed=False.
    """
    roots = [str(a) for a in args if a]
    for extra in (directory, directories):
        if extra:
            roots += [str(r) for r in make_list(extra, commaparse=False) if r]
    roots = list(dict.fromkeys(roots)) or [os.getcwd()]

    allowed = get_allowed_predicate(allowed=allowed, cfg=cfg, **kwargs)

    dirs, files = [], []
    for root in roots:
        if not os.path.isdir(root):
            continue
        if recursive:
            for walk_root, dnames, fnames in os.walk(root):
                # prune excluded dirs so we never descend into them
                dnames[:] = [d for d in dnames if allowed(os.path.join(walk_root, d))]
                dirs += [os.path.join(walk_root, d) for d in dnames]
                if include_files:
                    files += [os.path.join(walk_root, fn) for fn in fnames
                              if allowed(os.path.join(walk_root, fn))]
        else:
            for name in os.listdir(root):
                full = os.path.join(root, name)
                if os.path.isdir(full):
                    if allowed(full):
                        dirs.append(full)
                elif include_files and os.path.isfile(full):
                    if allowed(full):
                        files.append(full)
    return dirs, files

def get_file_filters(*args, **kwargs):
    directories = ensure_directories(*args, **kwargs)
    recursive = kwargs.get('recursive', True)
    include_files = kwargs.get('include_files', True)
    cfg = define_defaults(**kwargs)
    allowed = kwargs.get("allowed") or make_allowed_predicate(cfg)
    return directories, cfg, allowed, include_files, recursive
