# -*- 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


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):
    """本机执行器：直接 subprocess.run。

    资产文件在 ASSETS_DIR 中已存在，setup/upload_assets 只建目录结构，
    _write_config 写渲染后的 config.sh，run_module 用 subprocess 调 bash。
    """

    def __init__(self, run_id: str):
        super().__init__(run_id)
        self._local_workdir: Optional[Path] = None

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

    def setup(self) -> str:
        # 本地用 tempfile 创建临时工作目录
        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:
        # setup 时已完成复制，无需额外操作
        pass

    def _write_config(self, wd: str, config_text: str) -> None:
        config_path = Path(wd) / "lib" / "config.sh"
        config_path.write_text(config_text, encoding="utf-8")

    def _exec(self, command: str, timeout: int) -> str:
        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):
        # 本地模式，文件已在 setup 时复制
        pass

    def cleanup(self) -> None:
        if 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 解密后传入，不在初始化参数中硬编码。
    """

    def __init__(self, run_id: str, host: str, port: int,
                 username: str, password: str, 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.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 连接。"""
        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)
            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 _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:
        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
