# -*- coding: utf-8 -*-
"""
executor.py — 检测执行器

职责：
  - 在目标服务器上布置 assets/ 为 <workdir>/lib/ 结构
  - 注入 LIB_DIR 并执行指定模块
  - 收集原始 stdout 返回

执行器类型：
  - LocalExecutor：本机直接 subprocess（模块文件已在 assets/）
  - SSHExecutor：远程 paramiko（SFTP 上传 → SSH 执行）
"""

from __future__ import annotations

import abc
import logging
import os
import socket
import subprocess
import tempfile
import time
import shutil
from pathlib import Path
from typing import Dict, List, Optional

from .paths import (
    ASSETS_DIR, SYSTEM_ASSETS_DIR, SERVICE_ASSETS_DIR,
    COMMON_SH, CONFIG_TEMPLATE,
)
from .check_modules import CheckModule

logger = logging.getLogger("service_monitor.executor")

# 目标机上的工作目录前缀（后端根据 run_id 创建子目录）
_REMOTE_BASE = "/tmp/check_modules"

# ============================================================
# paramiko 可用性检查
# ============================================================
_HAS_PARAMIKO = True
_paramiko = None
try:
    import paramiko as _paramiko_mod
    _paramiko = _paramiko_mod
except ImportError:
    _HAS_PARAMIKO = False


def get_docker_host_ip() -> str:
    """获取 Docker 宿主机 IP。

    优先级：
    1. 环境变量 DOCKER_HOST_IP
    2. 从 ip route 解析网关 IP（Linux）
    3. 回退到 host.docker.internal

    Returns:
        宿主机 IP 地址字符串
    """
    # 1. 环境变量（最高优先级，允许用户覆盖）
    ip = os.environ.get("DOCKER_HOST_IP")
    if ip:
        logger.debug("使用环境变量 DOCKER_HOST_IP: %s", ip)
        return ip

    # 2. ip route 解析（Linux Docker）
    try:
        result = subprocess.run(
            ["ip", "route"],
            capture_output=True, text=True, timeout=5
        )
        for line in result.stdout.split("\n"):
            if "default" in line:
                parts = line.split()
                if len(parts) >= 3:
                    gateway_ip = parts[2]
                    logger.debug("从 ip route 解析宿主机 IP: %s", gateway_ip)
                    return gateway_ip
    except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
        logger.debug("ip route 解析失败: %s", e)

    # 3. 回退到 host.docker.internal
    logger.debug("回退到 host.docker.internal")
    return "host.docker.internal"


class DependencyError(Exception):
    """Python 依赖缺失异常。"""
    pass


class SSHConnectionError(Exception):
    """SSH 连接异常（含分类错误码）。

    Attributes:
        error_code: 错误码（DEPENDENCY_MISSING / AUTH_FAILED / SSH_ERROR /
                    TIMEOUT / CONNECTION_REFUSED / DNS_ERROR / UNKNOWN）
        detail: 原始异常信息
    """
    def __init__(self, message: str, error_code: str = "UNKNOWN", detail: str = ""):
        super().__init__(message)
        self.error_code = error_code
        self.detail = detail


def classify_ssh_error(exc: Exception) -> SSHConnectionError:
    """将 SSH 连接异常分类为 SSHConnectionError。

    根据异常类型返回用户友好的错误消息和错误码。
    """
    if isinstance(exc, DependencyError):
        return SSHConnectionError(
            message=str(exc),
            error_code="DEPENDENCY_MISSING",
            detail=str(exc),
        )

    if _HAS_PARAMIKO and isinstance(exc, _paramiko.AuthenticationException):
        return SSHConnectionError(
            message="认证失败：用户名或密码错误",
            error_code="AUTH_FAILED",
            detail=str(exc),
        )

    if _HAS_PARAMIKO and isinstance(exc, _paramiko.SSHException):
        return SSHConnectionError(
            message=f"SSH 协议错误：{exc}",
            error_code="SSH_ERROR",
            detail=str(exc),
        )

    if isinstance(exc, (socket.timeout, TimeoutError)):
        return SSHConnectionError(
            message="连接超时：目标不可达或端口未开放",
            error_code="TIMEOUT",
            detail=str(exc),
        )

    # OSError 子类（如 socket.error）检查消息内容
    if isinstance(exc, OSError):
        exc_str = str(exc).lower()
        if "timed out" in exc_str or "timeout" in exc_str:
            return SSHConnectionError(
                message="连接超时：目标不可达或端口未开放",
                error_code="TIMEOUT",
                detail=str(exc),
            )
        if "unable to connect" in exc_str or "connection refused" in exc_str:
            return SSHConnectionError(
                message="连接被拒绝：目标端口未开放或 SSH 服务未运行",
                error_code="CONNECTION_REFUSED",
                detail=str(exc),
            )
        if "network is unreachable" in exc_str or "no route to host" in exc_str:
            return SSHConnectionError(
                message="网络不可达：目标主机不存在或网络配置错误",
                error_code="NETWORK_UNREACHABLE",
                detail=str(exc),
            )

    if isinstance(exc, ConnectionRefusedError):
        return SSHConnectionError(
            message="连接被拒绝：目标端口未开放或 SSH 服务未运行",
            error_code="CONNECTION_REFUSED",
            detail=str(exc),
        )

    if isinstance(exc, socket.gaierror):
        return SSHConnectionError(
            message=f"主机名解析失败：{exc}",
            error_code="DNS_ERROR",
            detail=str(exc),
        )

    # 兜底
    return SSHConnectionError(
        message=f"SSH 连接失败：{exc}",
        error_code="UNKNOWN",
        detail=str(exc),
    )


class BaseExecutor(abc.ABC):
    """执行器抽象基类。"""

    def __init__(self, run_id: str):
        self.run_id = run_id
        self._workdir: Optional[str] = None   # 目标机上的工作目录

    @property
    @abc.abstractmethod
    def is_local(self) -> bool:
        """是否为本地执行器。"""

    @abc.abstractmethod
    def _exec(self, command: str, timeout: int) -> str:
        """在目标机上执行命令，返回 stdout。"""

    @abc.abstractmethod
    def _upload_file(self, local_path: Path, remote_path: str):
        """上传单个文件到目标机。"""

    def _remote_workdir(self) -> str:
        """远程工作目录路径。"""
        return f"{_REMOTE_BASE}_{self.run_id}"

    def setup(self) -> str:
        """在目标机上布置工作目录并渲染配置，返回 workdir 路径。"""
        wd = self._remote_workdir()
        self._workdir = wd
        self._exec(f"mkdir -p {wd}/lib/system {wd}/lib/service", timeout=10)
        return wd

    def upload_assets(self) -> None:
        """上传所有资产文件到目标机 <workdir>/lib/。"""
        wd = self._workdir
        if not wd:
            raise RuntimeError("setup() 未调用，workdir 为空")

        # 按 lib/ 结构上传
        self._upload_file(COMMON_SH, f"{wd}/lib/common.sh")
        self._upload_file(CONFIG_TEMPLATE, f"{wd}/lib/config.sh")

        for f in SYSTEM_ASSETS_DIR.iterdir():
            if f.suffix == ".sh":
                self._upload_file(f, f"{wd}/lib/system/{f.name}")

        for f in SERVICE_ASSETS_DIR.iterdir():
            if f.suffix == ".sh":
                self._upload_file(f, f"{wd}/lib/service/{f.name}")

    def run_module(self, module: CheckModule, config_text: str, timeout: int = 90) -> str:
        """执行单个检测模块，返回原始 stdout。

        步骤：
          1. 覆盖写入渲染后的 config.sh
          2. 设 LIB_DIR=<workdir> 执行 bash 模块
        """
        wd = self._workdir
        if not wd:
            raise RuntimeError("setup() 未调用")

        # 写入渲染后的 config.sh
        self._write_config(wd, config_text)

        # 构造执行命令
        cmd = (
            f"export LIB_DIR={wd} && "
            f"cd {wd} && "
            f"chmod +x lib/{module.relpath} && "
            f"bash lib/{module.relpath}"
        )
        logger.info("执行模块: %s (%s)", module.name, module.id)
        t0 = time.time()
        result = self._exec(cmd, timeout=timeout)
        elapsed = time.time() - t0
        logger.info("模块 %s 完成 (%.1fs, %d 行)", module.id, elapsed, len(result.splitlines()))
        return result

    @abc.abstractmethod
    def _write_config(self, wd: str, config_text: str) -> None:
        """将渲染后的 config.sh 写入目标机。"""

    def cleanup(self) -> None:
        """清理目标机上的工作目录。"""
        wd = self._workdir
        if wd:
            try:
                self._exec(f"rm -rf {wd}", timeout=10)
                logger.info("清理工作目录: %s", wd)
            except Exception as e:
                logger.warning("清理工作目录失败: %s", e)
            self._workdir = None

    def test_connection(self) -> bool:
        """测试连接。

        Returns:
            True: 连接成功
            False: 连接失败（但不抛异常的情况）

        Raises:
            SSHConnectionError: SSH 连接异常（含分类错误码）
            DependencyError: 依赖缺失
        """
        try:
            result = self._exec("echo OK", timeout=10)
            return "OK" in result
        except (DependencyError, SSHConnectionError):
            # 这些异常已经分类好了，直接向上抛
            raise
        except Exception as e:
            logger.warning("连接测试失败: %s", e)
            # 分类并抛出
            raise classify_ssh_error(e)


# ============================================================
# 本地执行器
# ============================================================

class LocalExecutor(BaseExecutor):
    """本机执行器：通过 SSH 到宿主机执行巡检脚本。

    Docker 容器化部署下，本级巡检需要 SSH 到宿主机获取真实系统数据。
    宿主机 SSH 配置从环境变量读取：
      - LOCAL_SSH_USER: SSH 用户名（默认 root）
      - LOCAL_SSH_PASSWORD: SSH 密码（或）
      - LOCAL_SSH_KEY: SSH 私钥路径

    如果环境变量未配置，回退到本地 subprocess 执行（非 Docker 模式）。
    """

    def __init__(self, run_id: str):
        super().__init__(run_id)
        self._local_workdir: Optional[Path] = None
        self._ssh_executor: Optional["SSHExecutor"] = None
        self._use_ssh = False  # 是否使用 SSH 模式
        self._use_sudo = False  # 是否需要 sudo 提权

        # 检测是否需要 SSH 模式（容器内）
        self._host_ip = get_docker_host_ip()
        self._ssh_user = os.environ.get("LOCAL_SSH_USER", "root")
        self._ssh_password = os.environ.get("LOCAL_SSH_PASSWORD", "")
        self._ssh_key = os.environ.get("LOCAL_SSH_KEY", "")

        # 如果配置了 SSH 凭据，使用 SSH 模式
        if self._ssh_password or self._ssh_key:
            self._use_ssh = True
            # 如果 SSH 用户不是 root，需要 sudo 提权
            if self._ssh_user != "root":
                self._use_sudo = True
            logger.info("本级巡检将使用 SSH 到宿主机: %s@%s%s",
                        self._ssh_user, self._host_ip,
                        " (sudo)" if self._use_sudo else "")

    @property
    def is_local(self) -> bool:
        return True

    def _get_ssh_executor(self) -> "SSHExecutor":
        """获取 SSH 执行器实例。"""
        if self._ssh_executor is None:
            if not _HAS_PARAMIKO:
                raise DependencyError(
                    "paramiko 未安装，本级巡检 SSH 模式不可用。请执行 pip install paramiko"
                )

            if self._ssh_key:
                # 密钥认证
                self._ssh_executor = SSHExecutor(
                    run_id=self.run_id,
                    host=self._host_ip,
                    port=22,
                    username=self._ssh_user,
                    password="",  # 密钥模式不需要密码
                )
                # TODO: 支持 key_filename，当前 SSHExecutor 只支持密码认证
                # 暂时忽略密钥，后续扩展
                logger.warning("本级巡检 SSH 密钥认证暂未完全支持，请使用密码认证")
            else:
                # 密码认证
                self._ssh_executor = SSHExecutor(
                    run_id=self.run_id,
                    host=self._host_ip,
                    port=22,
                    username=self._ssh_user,
                    password=self._ssh_password,
                )
        return self._ssh_executor

    def setup(self) -> str:
        if self._use_ssh:
            # SSH 模式：在宿主机上创建工作目录，并同步 _workdir
            # 注意：run_module 读的是 self._workdir，必须同步
            self._workdir = self._get_ssh_executor().setup()
            return self._workdir
        else:
            # 本地模式：创建临时目录
            self._local_workdir = Path(tempfile.mkdtemp(prefix=f"mon_{self.run_id}_"))
            # 建 lib 子目录
            lib_dir = self._local_workdir / "lib"
            (lib_dir / "system").mkdir(parents=True, exist_ok=True)
            (lib_dir / "service").mkdir(parents=True, exist_ok=True)
            # 复制资产文件
            shutil.copy2(COMMON_SH, lib_dir / "common.sh")
            for f in SYSTEM_ASSETS_DIR.iterdir():
                if f.suffix == ".sh":
                    shutil.copy2(f, lib_dir / "system" / f.name)
            for f in SERVICE_ASSETS_DIR.iterdir():
                if f.suffix == ".sh":
                    shutil.copy2(f, lib_dir / "service" / f.name)
            self._workdir = str(self._local_workdir)
            return self._workdir

    def upload_assets(self) -> None:
        if self._use_ssh:
            # SSH 模式：上传资产到宿主机
            # 同步 _workdir 到内部 SSHExecutor（setup 已同步，这里确保一致）
            ssh_ex = self._get_ssh_executor()
            if not ssh_ex._workdir and self._workdir:
                ssh_ex._workdir = self._workdir
            ssh_ex.upload_assets()
        # 本地模式：setup 时已完成复制，无需额外操作

    def _write_config(self, wd: str, config_text: str) -> None:
        if self._use_ssh:
            # SSH 模式：通过 SFTP 写入
            self._get_ssh_executor()._write_config(wd, config_text)
        else:
            # 本地模式：直接写入文件
            config_path = Path(wd) / "lib" / "config.sh"
            config_path.write_text(config_text, encoding="utf-8")

    def _exec(self, command: str, timeout: int) -> str:
        if self._use_ssh:
            # SSH 模式：远程执行（sudo 提权）
            if self._use_sudo:
                # sudo 执行复杂命令：用 bash -c 包裹
                # 注意：单引号内的命令不能再有单引号，需转义
                escaped = command.replace("'", "'\\''")
                command = f"sudo bash -c '{escaped}'"
            return self._get_ssh_executor()._exec(command, timeout)
        else:
            # 本地模式：subprocess 执行
            try:
                r = subprocess.run(
                    ["bash", "-c", command],
                    capture_output=True, text=True, timeout=timeout,
                    encoding="utf-8", errors="replace",
                )
                output = r.stdout
                if r.stderr:
                    # 将 stderr 中非 command not found 的合并到 stdout（模块会输出 ERROR: 前缀）
                    for line in r.stderr.splitlines():
                        if "command not found" in line.lower():
                            continue
                        output += line + "\n"
                return output
            except subprocess.TimeoutExpired:
                logger.warning("命令执行超时 (%ds): %s", timeout, command[:80])
                return ""
            except FileNotFoundError as e:
                logger.error("bash 不可用: %s", e)
                return ""

    def _upload_file(self, local_path: Path, remote_path: str):
        if self._use_ssh:
            # SSH 模式：通过 SFTP 上传
            self._get_ssh_executor()._upload_file(local_path, remote_path)
        # 本地模式：文件已在 setup 时复制

    def cleanup(self) -> None:
        if self._use_ssh and self._ssh_executor:
            # SSH 模式：清理远程工作目录 + 关闭连接
            self._ssh_executor.cleanup()
            self._ssh_executor = None
        elif self._local_workdir and self._local_workdir.exists():
            # 本地模式：清理临时目录
            shutil.rmtree(self._local_workdir, ignore_errors=True)
            logger.info("清理本地工作目录: %s", self._local_workdir)
        self._workdir = None
        self._local_workdir = None


# ============================================================
# SSH 远程执行器
# ============================================================

class SSHExecutor(BaseExecutor):
    """远程执行器：paramiko SSH + SFTP。

    需要：paramiko，PyPI 包。
    密码从 target 解密后传入，不在初始化参数中硬编码。

    自动检测 sudo：
      - 连接后自动检查用户是否为 root
      - 非 root 用户自动启用 sudo 提权
      - sudo 失败时给出明确提示
    """

    def __init__(self, run_id: str, host: str, port: int,
                 username: str, password: str, use_sudo: bool = False,
                 connect_timeout: int = 15):
        super().__init__(run_id)
        if not _HAS_PARAMIKO:
            raise DependencyError(
                "paramiko 未安装，请在服务器执行 pip install paramiko"
            )
        self.host = host
        self.port = port
        self.username = username
        self.password = password
        self.use_sudo = use_sudo  # 初始值，连接后会自动检测
        self._sudo_checked = False  # 是否已检测过 sudo
        self.connect_timeout = connect_timeout
        self._ssh_client = None
        self._sftp_client = None

    @property
    def is_local(self) -> bool:
        return False

    def _get_ssh(self):
        """懒加载 SSH 连接，并自动检测 sudo 提权需求。"""
        if self._ssh_client is None:
            self._ssh_client = _paramiko.SSHClient()
            self._ssh_client.set_missing_host_key_policy(_paramiko.AutoAddPolicy())
            try:
                self._ssh_client.connect(
                    hostname=self.host, port=self.port,
                    username=self.username, password=self.password,
                    timeout=self.connect_timeout,
                    allow_agent=False, look_for_keys=False,
                )
                logger.info("SSH 连接成功: %s@%s:%d", self.username, self.host, self.port)

                # 自动检测 sudo 提权需求（仅首次连接）
                if not self._sudo_checked:
                    self._detect_sudo_need()
                    self._sudo_checked = True

            except Exception as e:
                try:
                    self._ssh_client.close()
                except Exception:
                    pass
                self._ssh_client = None
                raise classify_ssh_error(e) from e
        return self._ssh_client

    def _detect_sudo_need(self):
        """检测是否需要 sudo 提权，并尝试自动配置免密 sudo。

        逻辑：
          1. 执行 whoami 检查当前用户
          2. 如果是 root，不需要 sudo
          3. 如果不是 root，测试 docker 是否可直接执行
          4. 如果 docker 需要 sudo，测试 sudo 是否可用
          5. 如果 sudo 需要密码，尝试用 SSH 密码自动配置免密 sudoers
        """
        try:
            _, stdout, _ = self._ssh_client.exec_command("whoami", timeout=5)
            current_user = stdout.read().decode("utf-8").strip()

            if current_user == "root":
                self.use_sudo = False
                logger.debug("SSH 用户为 root，无需 sudo 提权")
                return

            # 检测 docker 是否可直接执行（无需 sudo）
            _, stdout, _ = self._ssh_client.exec_command("docker ps &>/dev/null && echo OK", timeout=10)
            docker_ok = stdout.read().decode("utf-8").strip()

            if docker_ok == "OK":
                self.use_sudo = False
                logger.info("用户 '%s' 可直接执行 docker 命令", current_user)
                return

            # docker 需要 sudo，测试免密 sudo
            logger.info("用户 '%s' 执行 docker 需要 sudo 提权", current_user)
            _, stdout, _ = self._ssh_client.exec_command("sudo -n docker ps &>/dev/null && echo OK", timeout=10)
            sudo_docker_ok = stdout.read().decode("utf-8").strip()

            if sudo_docker_ok == "OK":
                self.use_sudo = True
                logger.info("免密 sudo 可用，已启用 sudo 提权")
                return

            # sudo 需要密码，尝试自动配置
            logger.info("用户 '%s' sudo 需要密码，尝试自动配置免密 sudoers...", current_user)
            if self._try_configure_passwordless_sudo(current_user):
                self.use_sudo = True
                logger.info("免密 sudo 配置成功")
            else:
                logger.warning(
                    "用户 '%s' 无法配置免密 sudo，巡检可能失败。"
                    "请手动在目标服务器执行: "
                    "echo '%s ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/%s",
                    current_user, current_user, current_user
                )
                # 仍然尝试用 sudo
                self.use_sudo = True

        except Exception as e:
            logger.warning("检测 sudo 需求失败: %s，默认启用 sudo", e)
            self.use_sudo = True

    def _try_configure_passwordless_sudo(self, username: str) -> bool:
        """尝试用 SSH 密码自动配置免密 sudoers。

        Returns:
            True: 配置成功
            False: 配置失败（可能密码不是 sudo 密码，或其他原因）
        """
        if not self.password:
            logger.debug("无 SSH 密码，无法尝试配置 sudo")
            return False

        try:
            # 构造 sudoers 配置命令
            sudoers_entry = f"{username} ALL=(ALL) NOPASSWD: ALL"
            cmd = (
                f"echo '{sudoers_entry}' | sudo -S tee /etc/sudoers.d/{username} > /dev/null 2>&1 && "
                f"sudo -S chmod 440 /etc/sudoers.d/{username} 2>/dev/null"
            )

            # 用 SSH 密码尝试 sudo（通过 stdin 传递密码）
            # 注意：这个操作只有在 SSH 密码 = sudo 密码时才能成功
            stdin, stdout, stderr = self._ssh_client.exec_command(
                f"echo '{self.password}' | {cmd}",
                timeout=15
            )

            # 检查执行结果
            exit_code = stdout.channel.recv_exit_status()
            if exit_code == 0:
                # 验证配置是否生效
                _, stdout2, _ = self._ssh_client.exec_command("sudo -n whoami 2>&1", timeout=10)
                result = stdout2.read().decode("utf-8").strip()
                return result == "root"
            else:
                logger.debug("sudoers 配置命令执行失败 (exit code: %d)", exit_code)
                return False

        except Exception as e:
            logger.debug("自动配置 sudoers 失败: %s", e)
            return False

    def _get_sftp(self):
        if self._sftp_client is None:
            ssh = self._get_ssh()
            self._sftp_client = ssh.open_sftp()
        return self._sftp_client

    def _exec(self, command: str, timeout: int) -> str:
        """执行命令，如果 use_sudo=True 则通过 sudo 提权。"""
        # sudo 提权：用 bash -c 包裹命令
        if self.use_sudo:
            # 单引号内的命令不能再有单引号，需转义
            escaped = command.replace("'", "'\\''")
            command = f"sudo bash -c '{escaped}'"

        ssh = self._get_ssh()
        try:
            _, stdout, stderr = ssh.exec_command(command, timeout=timeout)
            exit_code = stdout.channel.recv_exit_status()
            out = stdout.read().decode("utf-8", errors="replace")
            err = stderr.read().decode("utf-8", errors="replace")
            if err:
                for line in err.splitlines():
                    if "command not found" in line.lower():
                        continue
                    out += line + "\n"
            return out
        except Exception as e:
            logger.warning("SSH 执行失败: %s (cmd: %s)", e, command[:60])
            return ""

    def _upload_file(self, local_path: Path, remote_path: str):
        sftp = self._get_sftp()
        try:
            sftp.put(str(local_path), remote_path)
            logger.debug("上传: %s → %s", local_path.name, remote_path)
        except Exception as e:
            raise IOError(f"上传文件失败 {local_path.name}: {e}") from e

    def _write_config(self, wd: str, config_text: str) -> None:
        # SSH 模式：通过 SFTP 写入渲染后的 config.sh
        sftp = self._get_sftp()
        remote_config = f"{wd}/lib/config.sh"
        with sftp.open(remote_config, "w") as f:
            f.write(config_text)

    def cleanup(self) -> None:
        super().cleanup()
        try:
            if self._sftp_client:
                self._sftp_client.close()
        except Exception:
            pass
        try:
            if self._ssh_client:
                self._ssh_client.close()
        except Exception:
            pass
        self._sftp_client = None
        self._ssh_client = None
