import argparse
import ast
import importlib
import logging
import os
import json
import sys
from contextlib import contextmanager, nullcontext
from types import ModuleType
from pathlib import Path
from typing import ContextManager, Sequence

from rich.console import Console
from rich.logging import RichHandler
from rich.table import Table

from smooth_criminal.analizer import analyze_ast
from smooth_criminal.benchmark import benchmark_jam
from smooth_criminal.dashboard import render_dashboard
from smooth_criminal.memory import (
    suggest_boost,
    clear_execution_history,
    export_execution_history,
    score_function,
)
from smooth_criminal.core import play_mj_effect, set_mj_mode


log_level = os.getenv("LOG_LEVEL", "INFO").upper()
numeric_level = getattr(logging, log_level, logging.INFO)

logging.basicConfig(
    level=numeric_level,
    format="%(message)s",
    handlers=[RichHandler(rich_tracebacks=True, markup=True)],
    force=True,
)

logger = logging.getLogger("SmoothCriminal")

def main():
    parser = argparse.ArgumentParser(description="Smooth Criminal CLI")
    parser.add_argument(
        "--mj-mode",
        action="store_true",
        help="Activa efectos MJ cuando hay mejoras de rendimiento",
    )
    subparsers = parser.add_subparsers(dest="command")

    # Comando 'analyze'
    analyze_parser = subparsers.add_parser("analyze", help="Analiza un archivo Python en busca de optimizaciones.")
    analyze_parser.add_argument("filepath", help="Ruta del script a analizar")

    # Comando 'suggest'
    suggest_parser = subparsers.add_parser("suggest", help="Sugiere el mejor decorador para una función registrada.")
    suggest_parser.add_argument("func_name", help="Nombre de la función a consultar")

    # Comando 'dashboard'
    subparsers.add_parser("dashboard", help="Muestra un resumen del historial de optimizaciones.")

    # Comando 'clean'
    subparsers.add_parser("clean", help="Elimina el historial de ejecuciones registrado.")

    # Comando 'export'
    export_parser = subparsers.add_parser(
        "export", help="Exporta el historial como archivo CSV, JSON, XLSX o Markdown."
    )
    export_parser.add_argument("filepath", help="Ruta del archivo de salida")
    export_parser.add_argument(
        "--format",
        choices=["csv", "json", "xlsx", "md"],
        default="csv",
        help="Formato de exportación",
    )

    # Comando 'score'
    score_parser = subparsers.add_parser("score", help="Muestra una puntuación de optimización para una función.")
    score_parser.add_argument("func_name", help="Nombre de la función a evaluar")

    # Comando 'jam-test'
    jam_parser = subparsers.add_parser(
        "jam-test", help="Benchmark de backends de jam para una función"
    )
    jam_parser.add_argument(
        "func_path", help="Ruta a la función en formato modulo:funcion"
    )
    jam_parser.add_argument(
        "--workers", type=int, default=1, help="Número de workers a utilizar"
    )
    jam_parser.add_argument(
        "--reps", type=int, default=1, help="Número de repeticiones del benchmark"
    )
    jam_parser.add_argument(
        "--silent",
        action="store_true",
        help="Muestra solo el resultado en formato JSON",
    )

    args = parser.parse_args()
    set_mj_mode(args.mj_mode)
    if not getattr(args, "silent", False):
        show_intro()

    if args.command == "analyze":
        analyze_file(args.filepath)
    elif args.command == "suggest":
        handle_suggestion(args.func_name)
    elif args.command == "dashboard":
        render_dashboard()
    elif args.command == "clean":
        handle_clean()
    elif args.command == "export":
        handle_export(args.filepath, args.format)
    elif args.command == "score":
        handle_score(args.func_name)
    elif args.command == "jam-test":
        handle_jam_test(
            args.func_path, args.workers, args.reps, args.silent, args.mj_mode
        )

    else:
        logger.warning(
            "[bold red]Annie, are you OK?[/bold red] "
            "Use '[green]smooth-criminal analyze <file.py>[/green]', "
            "'[green]smooth-criminal suggest <func>[/green]', "
            "'[green]smooth-criminal dashboard[/green]' or "
            "'[green]smooth-criminal export <file> --format csv/json/xlsx/md[/green]'."
        )

def show_intro():
    logger.info(
        "[bold magenta]Smooth Criminal[/bold magenta]\n"
        "[dim]By Adolfo – Powered by Python + MJ energy 🕺[/dim]"
    )

def analyze_file(filepath):
    logger.info(
        f"\n🎤 [cyan]Analyzing [bold]{filepath}[/bold] for optimization opportunities...[/cyan]\n"
    )

    try:
        with open(filepath, "r", encoding="utf-8") as source_file:
            source_code = source_file.read()
    except OSError as exc:
        logger.error(f"[red]No se pudo leer el archivo:[/red] {exc}")
        return

    try:
        tree = ast.parse(source_code, filename=filepath)
    except SyntaxError as exc:
        logger.error(
            f"[red]El archivo contiene errores de sintaxis y no pudo analizarse:[/red] {exc}"
        )
        return

    functions = []
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            snippet = ast.get_source_segment(source_code, node)
            if snippet is None:
                lines = source_code.splitlines()
                start = node.lineno - 1
                end = getattr(node, "end_lineno", start + 1)
                snippet = "\n".join(lines[start:end])
            functions.append((node.name, snippet))

    if not functions:
        logger.warning("[yellow]No se encontraron funciones para analizar.[/yellow]")
        return

    for name, snippet in functions:
        logger.info(
            f"\n🔎 [bold blue]Analyzing function:[/bold blue] [white]{name}[/white]"
        )
        analyze_ast(snippet)

def handle_suggestion(func_name):
    logger.info(
        f"\n🧠 [bold cyan]Consulting memory for:[/bold cyan] [white]{func_name}[/white]\n"
    )
    suggestion = suggest_boost(func_name)
    logger.info(suggestion)

def handle_clean():
    if clear_execution_history():
        logger.info("[green]Historial de ejecuciones borrado exitosamente.[/green]")
    else:
        logger.warning("[yellow]No se encontró historial para borrar.[/yellow]")

def handle_export(filepath, format):
    success = export_execution_history(filepath, format)
    if success:
        logger.info(
            f"[green]Historial exportado a [bold]{filepath}[/bold] como {format.upper()}.[/green]"
        )
    else:
        logger.warning("[yellow]No hay historial para exportar.[/yellow]")

def handle_score(func_name):
    score, summary = score_function(func_name)
    if score is None:
        logger.warning(f"[yellow]{summary}[/yellow]")
    else:
        logger.info(summary)
        color = "green" if score >= 80 else "yellow" if score >= 50 else "red"
        logger.info(f"[bold {color}]Optimization Score: {score}/100[/bold {color}]")


def _temp_sys_path(path: Path | Sequence[Path]):
    """Context manager that temporarily adds one or more paths to ``sys.path``."""

    if isinstance(path, Path):
        paths: Sequence[Path] = [path]
    else:
        paths = list(path)

    @contextmanager
    def _manager():
        inserted: list[str] = []
        if not paths:
            yield
            return

        for entry in reversed(paths):
            str_path = str(entry)
            if str_path not in sys.path:
                sys.path.insert(0, str_path)
                inserted.append(str_path)
        try:
            yield
        finally:
            for str_path in inserted:
                try:
                    sys.path.remove(str_path)
                except ValueError:
                    continue

    return _manager()


@contextmanager
def _import_module_from_path(module_path: str):
    """Importa un módulo desde ``module_path`` gestionando colisiones."""

    path = Path(module_path).resolve()
    if not path.exists():
        raise FileNotFoundError(f"No existe el archivo: {module_path}")
    if path.is_dir():
        path = path / "__init__.py"
    if path.suffix != ".py":
        raise ImportError(
            "El módulo debe ser un archivo .py o un paquete con __init__.py"
        )

    module_dir = path.parent
    identifier_dirs: list[Path] = []
    current = module_dir
    while current.name and current.name.isidentifier():
        identifier_dirs.append(current)
        next_parent = current.parent
        if next_parent == current:
            break
        current = next_parent

    module_stem = path.stem
    max_relative_level = 0
    try:
        source_text = path.read_text(encoding="utf-8")
    except OSError:
        source_text = None
    else:
        try:
            tree = ast.parse(source_text)
        except SyntaxError:
            tree = None
        if tree is not None:
            for node in ast.walk(tree):
                if isinstance(node, ast.ImportFrom) and node.level:
                    max_relative_level = max(max_relative_level, node.level)
    if module_stem == "" or (module_stem != "__init__" and not module_stem.isidentifier()):
        raise ImportError(
            "El nombre del módulo debe ser un identificador de Python válido"
        )

    tried: set[tuple[Path, str]] = set()
    last_exc: Exception | None = None

    def _namespace_paths(root: Path, package_parts: Sequence[str]) -> list[Path]:
        paths: list[Path] = []

        def _add(candidate: Path) -> None:
            resolved = candidate.resolve()
            if resolved not in paths:
                paths.append(resolved)

        _add(root)
        if not package_parts:
            return paths

        top_pkg = package_parts[0]
        top_dir = root / top_pkg
        if (top_dir / "__init__.py").is_file():
            return paths

        parent = root.parent
        try:
            for sibling in parent.iterdir():
                if sibling == root:
                    continue
                candidate = sibling / top_pkg
                if candidate.is_dir():
                    _add(sibling)
        except OSError:
            pass

        return paths

    candidates: list[tuple[Path, list[str], str]] = []

    for idx, directory in enumerate(identifier_dirs):
        package_parts = [identifier_dirs[i].name for i in range(idx, -1, -1)]
        module_parts = package_parts.copy()
        if module_stem != "__init__":
            module_parts.append(module_stem)
        module_name = ".".join(module_parts) if module_parts else module_stem
        root_candidate = directory.parent
        key = (root_candidate, module_name)
        if key not in tried:
            tried.add(key)
            candidates.append((root_candidate, package_parts, module_name))

    if not candidates:
        module_name = module_stem
        root_candidate = path.parent
        candidates.append((root_candidate, [], module_name))

    if max_relative_level:
        filtered_candidates = [
            entry for entry in candidates if len(entry[1]) >= max_relative_level
        ]
    else:
        filtered_candidates = candidates

    if not filtered_candidates:
        filtered_candidates = candidates

    def _relative_depth(root: Path) -> int:
        try:
            return len(module_dir.relative_to(root).parts)
        except ValueError:
            return len(module_dir.parts)

    filtered_candidates.sort(
        key=lambda entry: (len(entry[1]), _relative_depth(entry[0]))
    )

    for root_candidate, package_parts, module_name in filtered_candidates:
        namespace_paths = _namespace_paths(root_candidate, package_parts)
        names_to_manage = [
            ".".join(package_parts[: index + 1])
            for index in range(len(package_parts))
        ]
        if module_name:
            names_to_manage.append(module_name)

        pre_existing_modules = set(sys.modules)
        local_saved: dict[str, ModuleType] = {}

        with _temp_sys_path(namespace_paths):
            managed_names = [name for name in names_to_manage if name]
            try:
                existing_modules = list(sys.modules.items())
                for full_name, module_obj in existing_modules:
                    for base_name in managed_names:
                        if full_name == base_name or full_name.startswith(base_name + "."):
                            if full_name not in local_saved:
                                local_saved[full_name] = module_obj
                                sys.modules.pop(full_name, None)
                            break
                module = importlib.import_module(module_name)
            except (ModuleNotFoundError, ImportError) as exc:
                new_modules = set(sys.modules) - pre_existing_modules
                for name in new_modules:
                    sys.modules.pop(name, None)
                sys.modules.update(local_saved)
                last_exc = exc
                continue

        try:
            yield module
            return
        finally:
            new_modules = set(sys.modules) - pre_existing_modules
            for name in new_modules:
                sys.modules.pop(name, None)
            sys.modules.update(local_saved)

    if last_exc is not None:
        raise last_exc
    raise ImportError("No se pudo determinar el paquete para el módulo solicitado")


def _split_func_path(func_path: str) -> tuple[str, str]:
    """Divide ``func_path`` en ``(module_identifier, attr_name)``.

    Usa :func:`str.rpartition` para ser compatible con rutas de Windows que
    incluyen una letra de unidad (``C:\\``) y otros casos donde ``:`` aparece en
    el identificador del módulo.  Si no se encuentra un separador válido o el
    atributo está vacío, se lanza :class:`ValueError` con un mensaje amigable.
    """

    module_identifier, sep, attr_name = func_path.rpartition(":")
    if not sep or not module_identifier or not attr_name:
        raise ValueError(
            "El formato debe ser 'modulo:funcion' o 'ruta/al/modulo.py:funcion'."
        )
    return module_identifier, attr_name


@contextmanager
def _import_callable(func_path: str):
    """Importa una función soportando rutas de archivos y paquetes."""

    module_identifier, attr_name = _split_func_path(func_path)

    try:
        module_cm: ContextManager[ModuleType] = nullcontext(
            importlib.import_module(module_identifier)
        )
    except (ModuleNotFoundError, TypeError, ValueError):
        module_cm = _import_module_from_path(module_identifier)

    with module_cm as module:
        try:
            yield getattr(module, attr_name)
        except AttributeError as exc:
            raise AttributeError(
                f"El módulo '{module.__name__}' no tiene el atributo '{attr_name}'."
            ) from exc


def handle_jam_test(
    func_path: str, workers: int, reps: int, silent: bool, mj_mode: bool
) -> None:
    backends = ["thread", "process", "async"]
    averages: dict[str, float] = {}
    fastest: str | None = None
    try:
        with _import_callable(func_path) as func:
            args = list(range(workers))

            if silent:
                logging.disable(logging.CRITICAL)

            totals = {b: 0.0 for b in backends}
            try:
                for _ in range(reps):
                    result = benchmark_jam(func, args, backends)
                    if not all(m.get("success") for m in result["metrics"]):
                        logger.error(
                            "[red]One or more backends failed during benchmarking.[/red]"
                        )
                        return
                    for metric in result["metrics"]:
                        totals[metric["backend"]] += metric["duration"]

                averages = {b: totals[b] / reps for b in backends}
                fastest = min(averages, key=averages.get)
                if silent:
                    print(json.dumps({"averages": averages, "fastest": fastest}))
                    return
            finally:
                if silent:
                    logging.disable(logging.NOTSET)
    except (ImportError, FileNotFoundError, AttributeError, ValueError) as exc:
        logger.error(
            "[red]No se pudo importar la función '%s': %s[/red]",
            func_path,
            exc,
        )
        if silent:
            logging.disable(logging.NOTSET)
        return

    max_duration = max(averages.values()) or 1.0
    min_duration = min(averages.values()) or 0.0
    improvement = ((max_duration - min_duration) / max_duration) * 100
    play_mj_effect(improvement, mj_mode)

    console = Console()
    table = Table(title="Resultados jam-test")
    table.add_column("Backend")
    table.add_column("Promedio (s)", justify="right")
    table.add_column("Comparativa")

    for backend in backends:
        duration = averages[backend]
        bar_length = int((duration / max_duration) * 40)
        bar = "█" * bar_length
        table.add_row(backend, f"{duration:.4f}", bar)

    console.print(table)
    logger.info("🎶 Just jammin' through those CPU cores! 🧠🕺")


if __name__ == "__main__":
    main()
