import os
import sys
import importlib
import importlib.util
import inspect
from pathlib import Path
from typing import List, Optional, Set

from ezvals.decorators import EvalFunction


def _clear_project_modules(directory: Path) -> None:
    """
    Clear cached modules from sys.modules whose __file__ is under the given directory.

    This enables hot-reloading: when a target module is edited and evals are rerun,
    the updated code is picked up instead of serving stale cached imports.
    """
    # Resolve symlinks to handle macOS /var -> /private/var etc
    dir_resolved = directory.resolve()
    dir_str = str(dir_resolved)
    # Ensure trailing separator for proper path matching (avoid /tmp/foo matching /tmp/foobar)
    if not dir_str.endswith(os.sep):
        dir_str += os.sep

    # Directories to exclude (virtual environments, installed packages)
    exclude_patterns = ['.venv', 'venv', 'site-packages', '.tox', '.nox', '__pycache__']

    to_remove = []
    for name, mod in list(sys.modules.items()):
        mod_file = getattr(mod, '__file__', None)
        if mod_file:
            try:
                mod_file_resolved = str(Path(mod_file).resolve())
                # Check if module file is under the directory
                if mod_file_resolved.startswith(dir_str):
                    # Skip if path contains excluded directories (venv, site-packages, etc.)
                    if any(pattern in mod_file_resolved for pattern in exclude_patterns):
                        continue
                    to_remove.append(name)
            except (OSError, ValueError):
                pass

    for name in to_remove:
        del sys.modules[name]

    # Delete .pyc files in __pycache__ to ensure fresh bytecode on reimport
    pycache_dir = dir_resolved / "__pycache__"
    if pycache_dir.exists():
        for pyc_file in pycache_dir.glob("*.pyc"):
            try:
                pyc_file.unlink()
            except OSError:
                pass

    importlib.invalidate_caches()


class EvalDiscovery:
    def __init__(self):
        self.discovered_functions: List[EvalFunction] = []

    def discover(
        self,
        path: str,
        dataset: Optional[str] = None,
        labels: Optional[List[str]] = None,
        function_name: Optional[str] = None
    ) -> List[EvalFunction]:
        self.discovered_functions = []
        path_obj = Path(path)
        
        if path_obj.is_file() and path_obj.suffix == '.py':
            self._discover_in_file(path_obj)
        elif path_obj.is_dir():
            self._discover_in_directory(path_obj)
        else:
            raise ValueError(f"Path {path} is neither a Python file nor a directory")
        
        # Apply filters
        filtered = self.discovered_functions
        
        if dataset:
            datasets = dataset.split(',') if ',' in dataset else [dataset]
            filtered = [f for f in filtered if f.dataset in datasets]
        
        if labels:
            label_set = set(labels)
            filtered = [f for f in filtered if any(l in label_set for l in f.labels)]
        
        if function_name:
            # Filter by function name
            # Match exact name or case variants (e.g., "func" matches "func[param1]")
            filtered = [
                f for f in filtered
                if f.func.__name__ == function_name
                or f.func.__name__.startswith(function_name + "[")
            ]
        
        return filtered

    def _discover_in_directory(self, directory: Path):
        for root, dirs, files in os.walk(directory):
            # Skip __pycache__ directories
            dirs[:] = [d for d in dirs if d != '__pycache__']
            
            for file in files:
                if file.endswith('.py') and not file.startswith('_'):
                    file_path = Path(root) / file
                    self._discover_in_file(file_path)

    def _discover_in_file(self, file_path: Path):
        parent_dir = str(file_path.parent.absolute())
        path_added = parent_dir not in sys.path
        if path_added:
            sys.path.insert(0, parent_dir)

        try:
            # Clear cached modules under this directory to enable hot-reloading
            _clear_project_modules(file_path.parent)

            spec = importlib.util.spec_from_file_location(file_path.stem, file_path)
            if not spec or not spec.loader:
                return

            module = importlib.util.module_from_spec(spec)
            module.__file__ = str(file_path)
            spec.loader.exec_module(module)

            file_defaults = getattr(module, 'ezvals_defaults', {})
            if not isinstance(file_defaults, dict):
                file_defaults = {}

            from ezvals.cases import generate_eval_functions

            def get_line_number(func):
                try:
                    return inspect.getsourcelines(func)[1]
                except (OSError, TypeError):
                    return 0

            functions_to_add = []
            for name, obj in inspect.getmembers(module):
                if isinstance(obj, EvalFunction):
                    # Check for mutual exclusion: input_loader and cases cannot be used together
                    if obj.input_loader and hasattr(obj, '__case_sets__'):
                        raise ValueError(f"Cannot use both cases and input_loader on {name}")
                    line_number = get_line_number(obj.func)
                    if hasattr(obj, '__case_sets__'):
                        for func in generate_eval_functions(obj):
                            self._apply_file_defaults(func, file_defaults)
                            if func.dataset == 'default':
                                func.dataset = file_path.stem
                            functions_to_add.append((line_number, func))
                    else:
                        self._apply_file_defaults(obj, file_defaults)
                        if obj.dataset == 'default':
                            obj.dataset = file_path.stem
                        functions_to_add.append((line_number, obj))

            functions_to_add.sort(key=lambda x: x[0])
            self.discovered_functions.extend(f for _, f in functions_to_add)

        except Exception as e:
            import traceback
            print(f"Warning: Could not import {file_path}: {e}")
            traceback.print_exc()
        finally:
            if path_added and parent_dir in sys.path:
                sys.path.remove(parent_dir)

    def get_unique_datasets(self) -> Set[str]:
        return {func.dataset for func in self.discovered_functions}

    def get_unique_labels(self) -> Set[str]:
        labels = set()
        for func in self.discovered_functions:
            labels.update(func.labels)
        return labels

    def _apply_file_defaults(self, func: EvalFunction, file_defaults: dict):
        """Apply file-level defaults to an EvalFunction instance."""
        if not file_defaults:
            return

        import copy

        valid_keys = {'dataset', 'labels', 'evaluators', 'target', 'input', 'reference',
                      'default_score_key', 'metadata'}
        invalid_keys = set(file_defaults.keys()) - valid_keys
        if invalid_keys:
            print(f"Warning: Unknown keys in ezvals_defaults: {', '.join(sorted(invalid_keys))}")

        if 'dataset' in file_defaults and func.dataset == 'default':
            func.dataset = file_defaults['dataset']

        # Apply list params only if decorator didn't provide them (None = not provided)
        for attr, provided_attr in [('labels', '_provided_labels'), ('evaluators', '_provided_evaluators')]:
            if attr in file_defaults and getattr(func, provided_attr, None) is None:
                setattr(func, attr, copy.deepcopy(file_defaults[attr]))

        if 'target' in file_defaults and func.target is None:
            func.target = file_defaults['target']

        # Handle metadata with deep merge
        if 'metadata' in file_defaults:
            file_meta = file_defaults['metadata']
            dec_meta = func.context_kwargs.get('metadata')
            if dec_meta is None:
                func.context_kwargs['metadata'] = copy.deepcopy(file_meta)
            elif isinstance(file_meta, dict) and isinstance(dec_meta, dict):
                merged = copy.deepcopy(file_meta)
                merged.update(dec_meta)
                func.context_kwargs['metadata'] = merged

        # Apply other context_kwargs defaults
        for key in ['default_score_key', 'input', 'reference']:
            if key in file_defaults and func.context_kwargs.get(key) is None:
                value = file_defaults[key]
                func.context_kwargs[key] = copy.deepcopy(value) if isinstance(value, (list, dict)) else value
