"""
Dependency Manager for AiCippy Installer.

Handles automatic detection and installation of required dependencies
across Windows, macOS, and Linux platforms.
"""

from __future__ import annotations

import json
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final

from aicippy.installer.platform_detect import (
    OperatingSystem,
    PlatformInfo,
    run_shell_command,
)
from aicippy.utils.logging import get_logger

if TYPE_CHECKING:
    from collections.abc import Callable

logger = get_logger(__name__)

# Required Python packages with minimum versions
REQUIRED_PACKAGES: Final[dict[str, str]] = {
    "pydantic": "2.6.0",
    "pydantic-settings": "2.2.0",
    "rich": "13.7.0",
    "typer": "0.12.0",
    "boto3": "1.34.0",
    "botocore": "1.34.0",
    "websockets": "12.0",
    "httpx": "0.27.0",
    "anyio": "4.3.0",
    "keyring": "25.0.0",
    "python-jose": "3.3.0",
    "aiofiles": "23.2.0",
    "tenacity": "8.2.0",
    "structlog": "24.1.0",
    "orjson": "3.9.0",
    "prompt-toolkit": "3.0.43",
    "feedparser": "6.0.11",
    "beautifulsoup4": "4.12.0",
    "lxml": "5.1.0",
    "markdown": "3.5.0",
    "cryptography": "42.0.0",
}

# Optional packages for enhanced features
OPTIONAL_PACKAGES: Final[dict[str, str]] = {
    "uvloop": "0.19.0",  # Unix only - faster event loop
}

# System dependencies per OS (package manager -> packages)
SYSTEM_DEPENDENCIES: Final[dict[OperatingSystem, dict[str, list[str]]]] = {
    OperatingSystem.MACOS: {
        "brew": ["openssl", "libffi"],
    },
    OperatingSystem.LINUX: {
        "apt": [
            "python3-dev",
            "libssl-dev",
            "libffi-dev",
            "build-essential",
        ],
        "yum": ["python3-devel", "openssl-devel", "libffi-devel", "gcc"],
        "dnf": ["python3-devel", "openssl-devel", "libffi-devel", "gcc"],
        "pacman": ["python", "openssl", "libffi", "base-devel"],
    },
    OperatingSystem.WINDOWS: {},  # Windows doesn't need system packages
}


@dataclass
class PackageStatus:
    """Status of a package installation."""

    name: str
    required_version: str
    installed_version: str | None
    is_installed: bool
    needs_upgrade: bool
    error: str | None = None


@dataclass
class DependencyCheckResult:
    """Result of dependency check."""

    packages: list[PackageStatus]
    missing_count: int
    upgrade_count: int
    all_satisfied: bool
    errors: list[str]
    unreliable: bool = False


def _validate_pip() -> bool:
    """Verify that pip is functional by running ``pip --version``.

    Returns:
        True if pip responds successfully, False otherwise.
    """
    try:
        result = subprocess.run(  # noqa: S603
            [sys.executable, "-m", "pip", "--version"],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if result.returncode != 0:
            logger.warning(
                "pip_validation_failed",
                returncode=result.returncode,
                stderr=result.stderr.strip(),
            )
            return False
        return True
    except FileNotFoundError:
        logger.warning("pip_validation_failed", error="python executable not found")
        return False
    except subprocess.TimeoutExpired:
        logger.warning("pip_validation_failed", error="pip --version timed out")
        return False
    except OSError as exc:
        logger.warning("pip_validation_failed", error=str(exc))
        return False


def parse_version(version_str: str) -> tuple[int, ...]:
    """Parse version string to comparable tuple.

    Handles PEP 440 versions including pre-release (a/b/rc), post-release
    (.postN), dev (.devN), and local (+local) segments. Only the numeric
    release components are returned for comparison.

    Args:
        version_str: Version string like "1.2.3", "1.2.3a1", "1.2.3rc1".

    Returns:
        Tuple of version numbers (e.g. (1, 2, 3)).
    """
    try:
        # Strip local version (+...) and build metadata
        clean = version_str.split("+")[0]
        # Strip pre-release, post-release, and dev suffixes using regex
        # Matches: .postN, .devN, aN, bN, rcN (PEP 440 suffixes)
        clean = re.split(r"(\.post|\.dev|a|b|rc)\d*", clean)[0]
        parts = clean.split(".")
        result: list[int] = []
        for p in parts:
            if p.isdigit():
                result.append(int(p))
        return tuple(result) if result else (0,)
    except (ValueError, AttributeError):
        return (0,)


def compare_versions(installed: str, required: str) -> int:
    """Compare two version strings.

    Args:
        installed: Installed version string.
        required: Required version string.

    Returns:
        -1 if installed < required, 0 if equal, 1 if installed > required.
    """
    inst_tuple = parse_version(installed)
    req_tuple = parse_version(required)

    # Pad shorter tuple with zeros
    max_len = max(len(inst_tuple), len(req_tuple))
    inst_tuple = inst_tuple + (0,) * (max_len - len(inst_tuple))
    req_tuple = req_tuple + (0,) * (max_len - len(req_tuple))

    if inst_tuple < req_tuple:
        return -1
    if inst_tuple > req_tuple:
        return 1
    return 0


def get_installed_packages() -> tuple[dict[str, str], bool]:
    """Get all installed Python packages and their versions.

    Returns:
        Tuple of (package_dict, pip_ok) where *pip_ok* is False when pip
        itself is broken (distinct from "no packages installed").
    """
    if not _validate_pip():
        logger.warning(
            "pip_not_functional",
            detail="pip pre-flight check failed; cannot list packages",
        )
        return {}, False

    # Primary: pip list --format=json
    try:
        result = subprocess.run(  # noqa: S603
            [sys.executable, "-m", "pip", "list", "--format=json"],
            capture_output=True,
            text=True,
            timeout=60,
        )
        if result.returncode == 0:
            packages = json.loads(result.stdout)
            return (
                {pkg["name"].lower().replace("_", "-"): pkg["version"] for pkg in packages},
                True,
            )
        logger.warning(
            "pip_list_json_nonzero",
            returncode=result.returncode,
            stderr=result.stderr.strip(),
        )
    except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError) as exc:
        logger.warning("pip_list_json_failed", error=str(exc))

    # Fallback: pip freeze
    try:
        result = subprocess.run(  # noqa: S603
            [sys.executable, "-m", "pip", "freeze"],
            capture_output=True,
            text=True,
            timeout=60,
        )
        if result.returncode == 0:
            packages: dict[str, str] = {}
            for line in result.stdout.strip().split("\n"):
                if "==" in line:
                    name, version = line.split("==", 1)
                    packages[name.lower().replace("_", "-")] = version
            return packages, True
        logger.warning(
            "pip_freeze_nonzero",
            returncode=result.returncode,
            stderr=result.stderr.strip(),
        )
    except (subprocess.TimeoutExpired, OSError) as exc:
        logger.warning("pip_freeze_failed", error=str(exc))

    # Both methods failed -- pip is not working
    logger.warning(
        "pip_list_all_methods_failed",
        detail="Neither 'pip list' nor 'pip freeze' succeeded",
    )
    return {}, False


def check_package_status(
    name: str,
    required_version: str,
    installed_packages: dict[str, str],
) -> PackageStatus:
    """Check the installation status of a single package.

    Args:
        name: Package name.
        required_version: Minimum required version.
        installed_packages: Dictionary of installed packages.

    Returns:
        PackageStatus for the package.
    """
    # Normalize name
    normalized_name = name.lower().replace("_", "-")
    installed_version = installed_packages.get(normalized_name)

    if installed_version is None:
        return PackageStatus(
            name=name,
            required_version=required_version,
            installed_version=None,
            is_installed=False,
            needs_upgrade=False,
        )

    needs_upgrade = compare_versions(installed_version, required_version) < 0

    return PackageStatus(
        name=name,
        required_version=required_version,
        installed_version=installed_version,
        is_installed=True,
        needs_upgrade=needs_upgrade,
    )


def check_dependencies(
    include_optional: bool = False,
) -> DependencyCheckResult:
    """Check all required dependencies.

    Args:
        include_optional: Whether to include optional packages.

    Returns:
        DependencyCheckResult with status of all packages. The *unreliable*
        flag is set when pip was broken and the results should not drive
        automatic installs.
    """
    installed, pip_ok = get_installed_packages()

    packages_to_check = dict(REQUIRED_PACKAGES)
    if include_optional:
        packages_to_check.update(OPTIONAL_PACKAGES)

    statuses: list[PackageStatus] = []
    errors: list[str] = []

    if not pip_ok:
        logger.warning(
            "dependency_check_unreliable",
            detail=(
                "pip is non-functional; dependency check results are "
                "unreliable and will not trigger automatic installs"
            ),
        )
        errors.append("pip is not functional -- dependency status is unreliable")

    for name, version in packages_to_check.items():
        status = check_package_status(name, version, installed)
        statuses.append(status)

    missing_count = sum(1 for s in statuses if not s.is_installed)
    upgrade_count = sum(1 for s in statuses if s.needs_upgrade)

    # When pip failed, report all_satisfied=True to avoid triggering
    # unnecessary installs based on unreliable data.
    if not pip_ok:
        return DependencyCheckResult(
            packages=statuses,
            missing_count=missing_count,
            upgrade_count=upgrade_count,
            all_satisfied=True,
            errors=errors,
            unreliable=True,
        )

    return DependencyCheckResult(
        packages=statuses,
        missing_count=missing_count,
        upgrade_count=upgrade_count,
        all_satisfied=missing_count == 0 and upgrade_count == 0,
        errors=errors,
        unreliable=False,
    )


def get_os_specific_install_hint(platform_info: PlatformInfo) -> str:
    """Get OS-specific help text for dependency installation failures.

    Args:
        platform_info: Platform information.

    Returns:
        Actionable hint string for the user's operating system.
    """
    if platform_info.is_macos:
        return (
            "macOS: Try 'brew install python3' or run "
            "'xcode-select --install' "
            "to install command-line developer tools"
        )
    if platform_info.is_linux:
        # Detect package manager for more specific advice
        for manager in ("apt", "apt-get"):
            if shutil.which(manager):
                return (
                    "Linux (Debian/Ubuntu): Try 'sudo apt install "
                    "python3-dev python3-pip "
                    "build-essential libssl-dev libffi-dev'"
                )
        if shutil.which("yum"):
            return (
                "Linux (RHEL/CentOS): Try 'sudo yum install "
                "python3-devel python3-pip "
                "gcc openssl-devel libffi-devel'"
            )
        if shutil.which("dnf"):
            return (
                "Linux (Fedora): Try 'sudo dnf install "
                "python3-devel python3-pip "
                "gcc openssl-devel libffi-devel'"
            )
        return (
            "Linux: Try installing python3-dev, python3-pip,"
            " and build-essential via your package manager"
        )
    if platform_info.is_windows:
        return (
            "Windows: Try 'winget install Python.Python.3' or download "
            "from https://www.python.org/downloads/"
        )
    return "Please ensure Python 3.11+ and pip are installed"


def detect_package_manager(platform_info: PlatformInfo) -> str | None:
    """Detect the available package manager on Unix systems.

    Uses shutil.which() for safe executable detection without shell=True.

    Args:
        platform_info: Platform information.

    Returns:
        Package manager name or None.
    """
    if platform_info.is_windows:
        return None

    if platform_info.is_macos:
        # Check for Homebrew using shutil.which (no shell needed)
        if shutil.which("brew"):
            return "brew"
        return None

    # Linux - check for various package managers
    managers = ["apt", "apt-get", "yum", "dnf", "pacman", "zypper"]
    for manager in managers:
        if shutil.which(manager):
            # Normalize apt-get to apt
            return "apt" if manager == "apt-get" else manager

    return None


def install_system_dependencies(
    platform_info: PlatformInfo,
    progress_callback: Callable[[str, int], None] | None = None,
) -> tuple[bool, list[str]]:
    """Install system-level dependencies.

    Args:
        platform_info: Platform information.
        progress_callback: Optional callback for progress updates.

    Returns:
        Tuple of (success, list of errors).
    """
    errors: list[str] = []

    if platform_info.is_windows:
        # Windows doesn't typically need system packages
        return True, errors

    package_manager = detect_package_manager(platform_info)
    if package_manager is None:
        return True, errors  # No package manager, skip system deps

    deps = SYSTEM_DEPENDENCIES.get(platform_info.os_type, {})
    packages = deps.get(package_manager, [])

    if not packages:
        return True, errors

    if progress_callback is not None:
        progress_callback("Installing system dependencies...", 0)

    # Build install command
    use_sudo = False
    if package_manager == "brew":
        cmd = f"brew install {' '.join(packages)}"
    elif package_manager in ("apt", "apt-get"):
        cmd = f"apt-get update && apt-get install -y {' '.join(packages)}"
        use_sudo = not platform_info.is_admin
    elif package_manager in ("yum", "dnf"):
        cmd = f"{package_manager} install -y {' '.join(packages)}"
        use_sudo = not platform_info.is_admin
    elif package_manager == "pacman":
        cmd = f"pacman -S --noconfirm {' '.join(packages)}"
        use_sudo = not platform_info.is_admin
    else:
        return True, errors

    if use_sudo and platform_info.has_sudo:
        cmd = f"sudo {cmd}"
    elif use_sudo and not platform_info.has_sudo:
        hint = get_os_specific_install_hint(platform_info)
        errors.append(f"System dependencies require root privileges. Please run with sudo. {hint}")
        return False, errors

    try:
        result = run_shell_command(
            cmd,
            platform_info,
            capture_output=True,
            timeout=300,
        )
        if result.returncode != 0:
            errors.append(f"Failed to install system dependencies: {result.stderr}")
            return False, errors
    except subprocess.TimeoutExpired:
        errors.append("System dependency installation timed out")
        return False, errors
    except Exception as exc:
        errors.append(f"Error installing system dependencies: {exc}")
        return False, errors

    if progress_callback is not None:
        progress_callback("System dependencies installed", 100)

    return True, errors


def install_python_packages(
    packages: list[PackageStatus],
    platform_info: PlatformInfo,
    progress_callback: Callable[[str, int], None] | None = None,
    upgrade_pip: bool = True,
    silent: bool = True,
) -> tuple[bool, list[str]]:
    """Install or upgrade Python packages.

    Args:
        packages: List of packages to install/upgrade.
        platform_info: Platform information.
        progress_callback: Optional callback for progress updates.
        upgrade_pip: Whether to upgrade pip first.
        silent: Run pip in quiet mode (no output) with auto-accept.

    Returns:
        Tuple of (success, list of errors).
    """
    errors: list[str] = []
    total = len(packages) + (1 if upgrade_pip else 0)
    current = 0

    # Pre-flight: verify pip is functional before attempting installs
    if not _validate_pip():
        hint = get_os_specific_install_hint(platform_info)
        errors.append(f"pip is not functional; cannot install packages. {hint}")
        logger.warning(
            "install_python_packages_aborted",
            reason="pip pre-flight validation failed",
        )
        return False, errors

    # Base pip flags for silent/background installation
    base_flags = (
        ["--quiet", "--disable-pip-version-check", "--no-warn-script-location"] if silent else []
    )

    # Upgrade pip first
    if upgrade_pip:
        if progress_callback is not None:
            progress_callback(
                "Upgrading pip...",
                int(current / total * 100),
            )

        try:
            cmd = [
                sys.executable,
                "-m",
                "pip",
                "install",
                "--upgrade",
                "pip",
                *base_flags,
            ]
            result = subprocess.run(  # noqa: S603
                cmd,
                capture_output=True,
                text=True,
                timeout=120,
                stdin=subprocess.DEVNULL,
            )
            if result.returncode != 0:
                logger.warning(
                    "pip_upgrade_failed",
                    returncode=result.returncode,
                    stderr=result.stderr.strip(),
                )
                errors.append(f"Warning: pip upgrade failed: {result.stderr.strip()}")
        except subprocess.TimeoutExpired:
            logger.warning("pip_upgrade_timeout")
            errors.append("Warning: pip upgrade timed out")
        except OSError as exc:
            logger.warning("pip_upgrade_error", error=str(exc))
            errors.append(f"Warning: pip upgrade failed: {exc}")

        current += 1

    # Install/upgrade packages -- all at once for efficiency
    if packages:
        package_specs = [f"{pkg.name}>={pkg.required_version}" for pkg in packages]

        if progress_callback is not None:
            progress_callback(
                f"Installing {len(packages)} packages...",
                int(current / total * 50),
            )

        try:
            cmd = [
                sys.executable,
                "-m",
                "pip",
                "install",
                "--upgrade",
                *base_flags,
                *package_specs,
            ]
            result = subprocess.run(  # noqa: S603
                cmd,
                capture_output=True,
                text=True,
                timeout=600,  # 10 minutes for bulk install
                stdin=subprocess.DEVNULL,
            )
            if result.returncode != 0:
                # Fall back to individual installs
                logger.warning(
                    "bulk_install_failed_fallback_individual",
                    returncode=result.returncode,
                    stderr=result.stderr.strip()[:500],
                    package_count=len(packages),
                )
                _install_packages_individually(
                    packages,
                    base_flags,
                    errors,
                    progress_callback,
                    current,
                    total,
                )
        except subprocess.TimeoutExpired:
            logger.warning("bulk_install_timeout")
            errors.append("Bulk package installation timed out")
        except OSError as exc:
            logger.warning("bulk_install_error", error=str(exc))
            errors.append(f"Error during bulk install: {exc}")

    if progress_callback is not None:
        progress_callback("Package installation complete", 100)

    # Post-install verification: confirm each package is present
    _post_install_verify(packages, errors)

    # If there were errors, append OS-specific installation guidance
    if errors:
        hint = get_os_specific_install_hint(platform_info)
        errors.append(f"Installation help: {hint}")

    return len(errors) == 0, errors


def _install_packages_individually(
    packages: list[PackageStatus],
    base_flags: list[str],
    errors: list[str],
    progress_callback: Callable[[str, int], None] | None,
    current: int,
    total: int,
) -> None:
    """Install packages one-by-one after bulk install failure.

    This is a private helper extracted from *install_python_packages* to
    keep the main function readable. It mutates *errors* in place.
    """
    for pkg in packages:
        if progress_callback is not None:
            action = "Upgrading" if pkg.needs_upgrade else "Installing"
            progress_callback(
                f"{action} {pkg.name}...",
                int(current / total * 100),
            )

        package_spec = f"{pkg.name}>={pkg.required_version}"
        try:
            cmd = [
                sys.executable,
                "-m",
                "pip",
                "install",
                "--upgrade",
                *base_flags,
                package_spec,
            ]
            result = subprocess.run(  # noqa: S603
                cmd,
                capture_output=True,
                text=True,
                timeout=180,
                stdin=subprocess.DEVNULL,
            )
            if result.returncode != 0:
                logger.warning(
                    "individual_install_failed",
                    package=pkg.name,
                    returncode=result.returncode,
                    stderr=result.stderr.strip()[:300],
                )
                errors.append(f"Failed to install {pkg.name}: {result.stderr.strip()}")
        except subprocess.TimeoutExpired:
            logger.warning(
                "individual_install_timeout",
                package=pkg.name,
            )
            errors.append(f"Installation of {pkg.name} timed out")
        except OSError as exc:
            logger.warning(
                "individual_install_error",
                package=pkg.name,
                error=str(exc),
            )
            errors.append(f"Error installing {pkg.name}: {exc}")

        current += 1


def _post_install_verify(
    packages: list[PackageStatus],
    errors: list[str],
) -> None:
    """Re-read installed packages and confirm each is present.

    Appends verification failures to *errors*.
    """
    if not packages:
        return

    installed, pip_ok = get_installed_packages()
    if not pip_ok:
        logger.warning(
            "post_install_verify_skipped",
            reason="pip not functional during verification",
        )
        errors.append("Post-install verification skipped: pip not functional")
        return

    for pkg in packages:
        normalized = pkg.name.lower().replace("_", "-")
        found_version = installed.get(normalized)
        if found_version is None:
            logger.warning(
                "post_install_verify_missing",
                package=pkg.name,
                required=pkg.required_version,
            )
            errors.append(f"Post-install verification: {pkg.name} not found after installation")
        elif compare_versions(found_version, pkg.required_version) < 0:
            logger.warning(
                "post_install_verify_version_mismatch",
                package=pkg.name,
                installed=found_version,
                required=pkg.required_version,
            )
            errors.append(
                f"Post-install verification: {pkg.name} "
                f"{found_version} < required {pkg.required_version}"
            )
        else:
            logger.debug(
                "post_install_verify_ok",
                package=pkg.name,
                version=found_version,
            )


def install_all_dependencies(
    platform_info: PlatformInfo,
    progress_callback: Callable[[str, int], None] | None = None,
    include_optional: bool = False,
) -> tuple[bool, list[str]]:
    """Install all required dependencies.

    Args:
        platform_info: Platform information.
        progress_callback: Optional callback for progress updates.
        include_optional: Whether to include optional packages.

    Returns:
        Tuple of (success, list of errors).
    """
    all_errors: list[str] = []

    # Phase 1: System dependencies
    if progress_callback is not None:
        progress_callback("Checking system dependencies...", 0)

    _, errors = install_system_dependencies(
        platform_info,
        progress_callback,
    )
    all_errors.extend(errors)

    # Phase 2: Check Python packages
    if progress_callback is not None:
        progress_callback("Checking Python packages...", 25)

    check_result = check_dependencies(include_optional)

    # If the check was unreliable, do not trigger installs
    if check_result.unreliable:
        logger.warning(
            "skipping_installs_unreliable_check",
            detail=(
                "Dependency check was unreliable (pip broken); "
                "skipping automatic package installation"
            ),
        )
        all_errors.extend(check_result.errors)
        if progress_callback is not None:
            progress_callback(
                "Dependency check unreliable -- skipping installs",
                100,
            )
        return len(all_errors) == 0, all_errors

    # Get packages that need installation or upgrade
    packages_to_install = [
        pkg for pkg in check_result.packages if not pkg.is_installed or pkg.needs_upgrade
    ]

    if not packages_to_install:
        if progress_callback is not None:
            progress_callback("All dependencies satisfied", 100)
        return True, all_errors

    # Phase 3: Install Python packages
    def wrapped_callback(msg: str, pct: int) -> None:
        if progress_callback is not None:
            # Scale 0-100 to 25-100
            scaled_pct = 25 + int(pct * 0.75)
            progress_callback(msg, scaled_pct)

    _, errors = install_python_packages(
        packages_to_install,
        platform_info,
        wrapped_callback,
    )
    all_errors.extend(errors)

    return len(all_errors) == 0, all_errors


def verify_installation() -> tuple[bool, str]:
    """Verify that all dependencies are properly installed.

    Returns:
        Tuple of (success, message).
    """
    result = check_dependencies()

    if result.unreliable:
        return False, "Dependency verification unreliable: pip not functional"

    if result.all_satisfied:
        return True, "All dependencies verified successfully"

    messages: list[str] = []
    for pkg in result.packages:
        if not pkg.is_installed:
            messages.append(f"Missing: {pkg.name} (>={pkg.required_version})")
        elif pkg.needs_upgrade:
            messages.append(
                f"Needs upgrade: {pkg.name} ({pkg.installed_version} -> >={pkg.required_version})"
            )

    return False, "\n".join(messages)
