"""Best-effort installers for optional advanced analysis engines."""

from __future__ import annotations

import os
import platform
import site
import shutil
import subprocess
import sys
import time
from typing import Callable, Dict, List, Optional

from .advanced_analyzers import available_engines


def missing_engines() -> List[str]:
    state = available_engines()
    return [name for name, ok in state.items() if not ok]


def install_missing_engines(
    timeout_seconds: int = 600,
    progress_callback: Optional[Callable[[str], None]] = None,
    event_callback: Optional[Callable[[dict], None]] = None,
) -> Dict[str, object]:
    """Attempt to install missing advanced engines.

    Returns:
      {"installed": [...], "failed": [...], "logs": ["..."]}
    """
    wanted = missing_engines()
    installed: List[str] = []
    failed: List[str] = []
    logs: List[str] = []
    hints: List[str] = []

    for engine in wanted:
        _emit_event(event_callback, engine=engine, phase="start", state="running", detail=f"Installing {engine}")
        if progress_callback:
            progress_callback(f"Installing {engine}...")
        ok, engine_logs = _install_engine(
            engine,
            timeout_seconds=timeout_seconds,
            progress_callback=progress_callback,
            event_callback=event_callback,
        )
        logs.extend(engine_logs)
        if ok:
            _emit_event(event_callback, engine=engine, phase="complete", state="done", detail=f"{engine} installed")
            installed.append(engine)
        else:
            _emit_event(event_callback, engine=engine, phase="complete", state="failed", detail=f"{engine} install failed")
            failed.append(engine)

    hints.extend(_install_hints(installed, failed))
    return {"installed": installed, "failed": failed, "logs": logs, "hints": hints}


def _install_engine(
    engine: str,
    timeout_seconds: int = 600,
    progress_callback: Optional[Callable[[str], None]] = None,
    event_callback: Optional[Callable[[dict], None]] = None,
) -> tuple[bool, List[str]]:
    commands = _install_commands(engine)
    logs: List[str] = []
    for cmd in commands:
        _emit_event(
            event_callback,
            engine=engine,
            phase="command",
            state="running",
            detail=f"{' '.join(cmd)}",
            manager=cmd[0] if cmd else "",
        )
        if progress_callback:
            progress_callback(f"{engine}: {' '.join(cmd)}")
        logs.append(f"try: {' '.join(cmd)}")
        try:
            proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_seconds)
        except (OSError, subprocess.TimeoutExpired) as exc:
            _emit_event(event_callback, engine=engine, phase="command", state="failed", detail=str(exc))
            logs.append(f"error: {exc}")
            continue
        _emit_event(
            event_callback,
            engine=engine,
            phase="command",
            state="done" if proc.returncode == 0 else "failed",
            detail=f"exit={proc.returncode}",
        )
        if proc.returncode == 0:
            # Verify on PATH. Windows package installs can succeed before PATH refresh;
            # wait briefly, then probe common install directories.
            if _wait_for_engine(engine, progress_callback=progress_callback, event_callback=event_callback):
                logs.append("installed")
                return True, logs
            _emit_event(event_callback, engine=engine, phase="path-check", state="running", detail="PATH probe")
            if _augment_path_for_engine(engine):
                _emit_event(event_callback, engine=engine, phase="path-check", state="done", detail="Added install dir to PATH")
                if _wait_for_engine(
                    engine,
                    timeout_seconds=6,
                    interval_seconds=1,
                    progress_callback=progress_callback,
                    event_callback=event_callback,
                ):
                    logs.append("installed (detected after PATH augmentation)")
                    return True, logs
            else:
                _emit_event(event_callback, engine=engine, phase="path-check", state="warn", detail="No install dir found on disk")
            logs.append("command succeeded but binary still not detected on PATH")
            continue
        stderr = (proc.stderr or "").strip().splitlines()
        logs.append(f"exit={proc.returncode} {stderr[-1] if stderr else ''}".strip())
    _emit_event(event_callback, engine=engine, phase="finalize", state="failed", detail="No install command succeeded")
    return False, logs


def _install_commands(engine: str) -> List[List[str]]:
    system = platform.system()
    cmds: List[List[str]] = []
    has_brew = shutil.which("brew") is not None
    has_choco = shutil.which("choco") is not None
    has_winget = shutil.which("winget") is not None
    has_pipx = shutil.which("pipx") is not None

    if engine == "semgrep":
        if has_brew:
            cmds.append(["brew", "install", "semgrep"])
        if has_pipx:
            cmds.append(["pipx", "install", "semgrep"])
        cmds.append([sys.executable, "-m", "pip", "install", "--user", "semgrep"])
        return cmds

    if engine == "codeql":
        if has_brew:
            cmds.append(["brew", "install", "codeql"])
        if system == "Windows" and has_choco:
            cmds.append(["choco", "install", "codeql", "-y", "--no-progress"])
        if system == "Windows" and has_winget:
            cmds.append(
                [
                    "winget",
                    "install",
                    "-e",
                    "--id",
                    "GitHub.codeql",
                    "--accept-source-agreements",
                    "--accept-package-agreements",
                ]
            )
        return cmds

    return cmds


def _wait_for_engine(
    engine: str,
    timeout_seconds: int = 20,
    interval_seconds: int = 2,
    progress_callback: Optional[Callable[[str], None]] = None,
    event_callback: Optional[Callable[[dict], None]] = None,
) -> bool:
    _emit_event(event_callback, engine=engine, phase="detect", state="running", detail="Probing engine availability")
    elapsed = 0
    while elapsed <= timeout_seconds:
        state = available_engines()
        if state.get(engine, False):
            _emit_event(event_callback, engine=engine, phase="detect", state="done", detail="Engine detected")
            return True
        if progress_callback and elapsed < timeout_seconds:
            progress_callback(f"Waiting for {engine} to become available on PATH...")
        if elapsed < timeout_seconds:
            _emit_event(
                event_callback,
                engine=engine,
                phase="detect",
                state="running",
                detail=f"Not yet detected ({elapsed}s/{timeout_seconds}s)",
            )
        time.sleep(interval_seconds)
        elapsed += interval_seconds
    _emit_event(event_callback, engine=engine, phase="detect", state="warn", detail="Detection timeout")
    return False


def _augment_path_for_engine(engine: str) -> bool:
    if platform.system() != "Windows":
        return False
    exe = f"{engine}.exe"
    for directory in _candidate_windows_bin_dirs():
        if not directory:
            continue
        candidate = os.path.join(directory, exe)
        if os.path.exists(candidate):
            existing_path = os.environ.get("PATH", "")
            parts = existing_path.split(os.pathsep) if existing_path else []
            if directory not in parts:
                os.environ["PATH"] = directory + os.pathsep + existing_path
            return True
    return False


def _candidate_windows_bin_dirs() -> List[str]:
    dirs: List[str] = []
    user_base = site.USER_BASE
    if user_base:
        dirs.append(os.path.join(user_base, "Scripts"))
    local_appdata = os.environ.get("LOCALAPPDATA", "")
    if local_appdata:
        dirs.append(os.path.join(local_appdata, "Programs", "Python", "Python311", "Scripts"))
        dirs.append(os.path.join(local_appdata, "Programs", "Python", "Python312", "Scripts"))
        dirs.append(os.path.join(local_appdata, "Programs", "Python", "Python313", "Scripts"))
    dirs.append(r"C:\ProgramData\chocolatey\bin")
    dirs.append(r"C:\Program Files\CodeQL")
    # Preserve order while removing duplicates.
    unique: List[str] = []
    seen = set()
    for item in dirs:
        norm = os.path.normcase(item)
        if norm in seen:
            continue
        seen.add(norm)
        unique.append(item)
    return unique


def _is_windows_admin() -> bool:
    if platform.system() != "Windows":
        return True
    try:
        import ctypes

        return bool(ctypes.windll.shell32.IsUserAnAdmin())
    except Exception:
        return False


def _install_hints(installed: List[str], failed: List[str]) -> List[str]:
    hints: List[str] = []
    if platform.system() != "Windows":
        return hints
    if failed and not _is_windows_admin():
        hints.append("Some Windows installers may require an Administrator terminal (Run as Administrator).")
    if installed:
        hints.append("If a newly installed engine is still not detected, restart the terminal to refresh PATH.")
    return hints


def _emit_event(callback: Optional[Callable[[dict], None]], **event: object) -> None:
    if callback is None:
        return
    callback(dict(event))
