# -*- coding: utf-8 -*-
"""
MailBot Agent — 邮件发送代理 + 远程桌面监控
轮询中央服务器获取邮件任务，通过 SMTP 发送，上报结果
内置向日葵/RustDesk/ToDesk远程使用监控，实时上报
Python 3.8 | 兼容 Win7/10/11
"""
import os
import sys
import re
import glob
import time
import uuid
import base64
# import hashlib  # 自更新已关闭
import configparser
import subprocess
import smtplib
import logging
import threading
from email.mime.text import MIMEText
from email.header import Header
from datetime import datetime, timedelta
from logging.handlers import TimedRotatingFileHandler

try:
    import requests
except ImportError:
    try:
        print('[错误] 缺少 requests 库，请安装: pip install requests')
    except Exception:
        pass
    sys.exit(1)

try:
    import ctypes
    _kernel32 = ctypes.windll.kernel32
except Exception:
    _kernel32 = None

VERSION = '1.0.3'

ACCOUNT = ''
SERVICE_NAME = ''
SERVER_HOST = ''
TASK_POLL_INTERVAL = 30
REMOTE_POLL_INTERVAL = 30
# UPDATE_CHECK_INTERVAL = 300  # 自更新已关闭

_config_path = ''


def _load_config():
    """从exe同目录的config.ini加载配置"""
    global ACCOUNT, SERVICE_NAME, SERVER_HOST, _config_path

    if getattr(sys, 'frozen', False):
        base_dir = os.path.dirname(sys.executable)
    else:
        base_dir = os.path.dirname(os.path.abspath(__file__))

    _config_path = os.path.join(base_dir, 'config.ini')

    if not os.path.exists(_config_path):
        try:
            print('[错误] 配置文件不存在: {}'.format(_config_path))
            print('请在exe同目录下创建 config.ini, 内容示例:')
            print('[agent]')
            print('account = TT')
            print('server_host = 192.168.2.55')
            print('server_port = 8000')
            input('按回车退出...')
        except Exception:
            pass
        sys.exit(1)

    config = configparser.ConfigParser(interpolation=None)
    config.read(_config_path, encoding='utf-8')

    ACCOUNT = config.get('agent', 'account', fallback='').strip()
    if not ACCOUNT:
        try:
            print('[错误] config.ini 缺少 account 配置')
            input('按回车退出...')
        except Exception:
            pass
        sys.exit(1)

    server_host = config.get('agent', 'server_host', fallback='192.168.2.55').strip()
    server_port = config.get('agent', 'server_port', fallback='8000').strip()
    SERVER_HOST = 'http://{}:{}/microservice-operations'.format(server_host, server_port)
    SERVICE_NAME = 'MailBotAgent_{}'.format(ACCOUNT)

    token = config.get('agent', 'token', fallback='').strip()
    token_expire = config.get('agent', 'token_expire', fallback='').strip()

    return token, token_expire


def _save_token(token, token_expire):
    """将token持久化到config.ini"""
    if not _config_path:
        return
    try:
        config = configparser.ConfigParser(interpolation=None)
        config.read(_config_path, encoding='utf-8')
        if not config.has_section('agent'):
            config.add_section('agent')
        config.set('agent', 'token', token)
        config.set('agent', 'token_expire', token_expire)
        with open(_config_path, 'w', encoding='utf-8') as f:
            config.write(f)
    except Exception as e:
        logging.getLogger('mailbot').warning('保存Token到配置文件失败: %s', e)


def _read_config_value(key, fallback=''):
    if not _config_path:
        return fallback
    try:
        config = configparser.ConfigParser(interpolation=None)
        config.read(_config_path, encoding='utf-8')
        return config.get('agent', key, fallback=fallback).strip()
    except Exception:
        return fallback


def _save_config_value(key, value):
    if not _config_path:
        return
    try:
        config = configparser.ConfigParser(interpolation=None)
        config.read(_config_path, encoding='utf-8')
        if not config.has_section('agent'):
            config.add_section('agent')
        config.set('agent', key, value)
        with open(_config_path, 'w', encoding='utf-8') as f:
            config.write(f)
    except Exception as e:
        logging.getLogger('mailbot').warning('保存配置项 %s 失败: %s', key, e)


_cached_mac = None
_cached_disk_sn = None


# ============================================================
#  设备信息采集（无 wmi 依赖，兼容 Win7/10/11）
# ============================================================

def get_mac():
    global _cached_mac
    if _cached_mac is not None:
        return _cached_mac
    try:
        mac_int = uuid.getnode()
        mac = ':'.join(('%012X' % mac_int)[i:i+2] for i in range(0, 12, 2))
        _cached_mac = mac
        return mac
    except Exception:
        pass
    return 'UNKNOWN'


def get_disk_sn():
    global _cached_disk_sn
    if _cached_disk_sn is not None:
        return _cached_disk_sn
    try:
        result = subprocess.run(
            ['wmic', 'diskdrive', 'get', 'serialnumber'],
            capture_output=True, text=True, timeout=10,
            creationflags=0x08000000
        )
        lines = [l.strip() for l in result.stdout.strip().split('\n') if l.strip()]
        for line in lines:
            if line.lower() != 'serialnumber' and line:
                _cached_disk_sn = line
                return line
    except Exception:
        pass
    return 'UNKNOWN'


def get_computer_name():
    return os.environ.get('COMPUTERNAME', 'UNKNOWN')


def get_username():
    try:
        result = subprocess.run(
            ['wmic', 'computersystem', 'get', 'username'],
            capture_output=True, text=True, timeout=10,
            creationflags=0x08000000
        )
        lines = [l.strip() for l in result.stdout.strip().split('\n') if l.strip()]
        for line in lines:
            if line.lower() != 'username' and line:
                parts = line.split('\\')
                return parts[-1] if parts else line
    except Exception:
        pass
    return os.environ.get('USERNAME', 'UNKNOWN')


def get_agent_id():
    return '{}_{}'.format(get_computer_name(), ACCOUNT)


# ============================================================
#  日志
# ============================================================

def setup_logger(agent_id):
    if getattr(sys, 'frozen', False):
        base_dir = os.path.dirname(sys.executable)
    else:
        base_dir = os.path.dirname(os.path.abspath(__file__))

    log_dir = os.path.join(base_dir, 'logs')
    if not os.path.exists(log_dir):
        os.makedirs(log_dir)

    logger = logging.getLogger('mailbot')
    if logger.handlers:
        return logger
    logger.setLevel(logging.DEBUG)

    fmt = logging.Formatter(
        '[%(asctime)s] [%(levelname)-7s] [agent={}] %(message)s'.format(agent_id),
        datefmt='%Y-%m-%d %H:%M:%S'
    )

    fh = TimedRotatingFileHandler(
        os.path.join(log_dir, 'agent.log'),
        when='midnight', backupCount=5, encoding='utf-8'
    )
    fh.setLevel(logging.DEBUG)
    fh.setFormatter(fmt)
    fh.suffix = '%Y%m%d'

    ch = logging.StreamHandler()
    ch.setLevel(logging.INFO)
    ch.setFormatter(fmt)

    logger.addHandler(fh)
    logger.addHandler(ch)

    _cleanup_old_logs(log_dir, keep_days=5)

    return logger


def _cleanup_old_logs(log_dir, keep_days=5):
    cutoff = time.time() - keep_days * 86400
    try:
        for f in os.listdir(log_dir):
            fp = os.path.join(log_dir, f)
            if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
                os.remove(fp)
    except Exception:
        pass


# ============================================================
#  API 客户端
# ============================================================

class ApiClient:
    HB_403_MAX = 5
    HB_404_MAX = 1500
    REGISTER_FAIL_MAX = 3

    def __init__(self, logger):
        self.logger = logger
        self.base_url = SERVER_HOST.rstrip('/')
        self.stopped = False
        self.token = ''
        self.token_expire = ''
        self._is_renewing = False
        self._hb_403_count = 0
        self._hb_404_count = 0
        self._register_fail_count = 0

    def _headers(self, with_token=True):
        h = {
            'X-Agent-ID': get_agent_id(),
            'X-MAC': get_mac(),
            'X-Disk-SN': get_disk_sn(),
            'Content-Type': 'application/json'
        }
        if with_token:
            h['X-Token'] = self.token
        return h

    def _request(self, method, path, data=None, need_auth=True, max_retries=3):
        url = self.base_url + path
        headers = self._headers(with_token=need_auth)

        for attempt in range(1, max_retries + 1):
            if self.stopped:
                return None
            try:
                resp = requests.request(method, url, json=data, headers=headers, timeout=15)
                try:
                    result = resp.json()
                except ValueError:
                    self.logger.warning('响应非JSON: %s, %d/%d', resp.text[:200], attempt, max_retries)
                    if attempt < max_retries:
                        time.sleep(30)
                    continue
                code = result.get('code', 500)
                msg = result.get('msg', '')

                if code == 200:
                    self._hb_403_count = 0
                    self._hb_404_count = 0
                    self._register_fail_count = 0
                    return result

                if code == 400:
                    self.logger.error('[%d] %s', code, msg)
                    return result

                if code == 401:
                    if self._is_renewing:
                        return None
                    if '过期' in msg:
                        self.logger.warning('[%d] %s, 尝试续签...', code, msg)
                        if self.renew_token():
                            headers = self._headers(with_token=True)
                            continue
                    else:
                        self.logger.warning('[%d] %s, 重新注册...', code, msg)
                        if self.register():
                            headers = self._headers(with_token=True)
                            continue
                    return None

                if code in (403, 404):
                    self.logger.warning('[%d] %s', code, msg)
                    return result

                if code == 429:
                    self.logger.warning('[%d] %s, 等待60秒...', code, msg)
                    time.sleep(60)
                    continue

                if code in (500, 502, 503, 504):
                    self.logger.warning('[%d] %s, %d/%d', code, msg, attempt, max_retries)
                    if attempt < max_retries:
                        time.sleep(30)
                    continue

                self.logger.warning('[%d] %s', code, msg)
                return result

            except requests.RequestException as e:
                self.logger.warning('网络异常: %s, %d/%d', str(e), attempt, max_retries)
                if attempt < max_retries:
                    time.sleep(30)

        self.logger.error('请求失败(重试%d次): %s %s', max_retries, method, path)
        return None

    def register(self):
        data = {
            'agent_id': get_agent_id(),
            'account': ACCOUNT,
            'mac': get_mac(),
            'disk_sn': get_disk_sn()
        }
        self.logger.info('注册设备: agent_id=%s, account=%s, mac=%s, disk_sn=%s',
                         data['agent_id'], data['account'], data['mac'], data['disk_sn'])

        result = self._request('POST', '/api/agent/register', data=data, need_auth=False)
        if result and result.get('code') == 200:
            self.token = result['data']['token']
            self.token_expire = result['data']['expire_time']
            self.logger.info('注册成功, Token有效期: %s', self.token_expire)
            _save_token(self.token, self.token_expire)
            self._register_fail_count = 0
            return True
        if result and result.get('code') in (400, 403, 404):
            self._register_fail_count += 1
            self.logger.error('注册失败[%d] %s, 连续%d/%d次',
                              result.get('code'), result.get('msg', ''),
                              self._register_fail_count, self.REGISTER_FAIL_MAX)
            if self._register_fail_count >= self.REGISTER_FAIL_MAX:
                self.logger.error('注册连续%d次永久错误(账号/设备指纹异常), 停止Agent', self._register_fail_count)
                self.stopped = True
        else:
            self.logger.error('注册失败(临时): %s', result)
        return False

    def renew_token(self):
        self._is_renewing = True
        try:
            self.logger.info('开始续签Token...')
            result = self._request('POST', '/api/agent/token/renew', data={})
        finally:
            self._is_renewing = False
        if result and result.get('code') == 200:
            self.token = result['data']['token']
            self.token_expire = result['data']['expire_time']
            self.logger.info('Token续签成功, 有效期: %s', self.token_expire)
            _save_token(self.token, self.token_expire)
            return True
        self.logger.error('Token续签失败')
        return False

    def check_token(self):
        if not self.token:
            return self.register()
        if self.token_expire:
            try:
                expire = datetime.strptime(self.token_expire, '%Y-%m-%d %H:%M:%S')
                if expire < datetime.now():
                    self.logger.info('Token已过期, 重新注册...')
                    return self.register()
                if expire - datetime.now() < timedelta(days=3):
                    self.logger.info('Token将在3天内过期, 续签...')
                    return self.renew_token()
            except ValueError:
                pass
        self.logger.debug('Token有效, 到期: %s', self.token_expire)
        return True

    def fetch_task(self):
        result = self._request('POST', '/api/agent/task/fetch', data={})
        if result and result.get('code') == 200:
            return result.get('data')
        return None

    def report_result(self, task_id, status, result_msg):
        data = {
            'task_id': task_id,
            'status': status,
            'result_msg': result_msg,
            'send_time': time.strftime('%Y-%m-%d %H:%M:%S')
        }
        self.logger.info('上报结果: task_id=%s, status=%s, msg=%s', task_id, status, result_msg)
        result = self._request('POST', '/api/agent/task/result', data=data)
        if result and result.get('code') == 200:
            return result
        return None

    def report_remote_status(self, status_data):
        self.logger.debug('上报在线状态')
        result = self._request('POST', '/api/agent/status/report', data=status_data, max_retries=1)
        if result and result.get('code') == 200:
            return result
        return None

    def heartbeat(self):
        """心跳上报: 带版本号, 服务端返回最新版本+指令"""
        data = {
            'version': VERSION,
            'computer_name': get_computer_name(),
            'username': get_username(),
            'timestamp': time.strftime('%Y-%m-%d %H:%M:%S')
        }
        saved = self.stopped
        result = self._request('POST', '/api/agent/heartbeat', data=data, max_retries=1)
        if result is None and not saved:
            self.stopped = False

        if result and result.get('code') == 403:
            self._hb_403_count += 1
            if self._hb_403_count >= self.HB_403_MAX:
                self.logger.error('心跳连续%d次收到403(设备指纹不匹配), 停止Agent', self._hb_403_count)
                self.stopped = True
            else:
                self.logger.warning('心跳[403], 连续%d/%d次', self._hb_403_count, self.HB_403_MAX)
        elif result and result.get('code') == 404:
            self._hb_404_count += 1
            if self._hb_404_count >= self.HB_404_MAX:
                self.logger.error('心跳连续%d次收到404, 停止Agent', self._hb_404_count)
                self.stopped = True
            else:
                self.logger.warning('心跳[404], 连续%d/%d次', self._hb_404_count, self.HB_404_MAX)

        return result


# ============================================================
#  自更新模块
# ============================================================

# ============================================================
#  自更新功能已关闭（注释保留）
# ============================================================
# class SelfUpdater:
#
#     def __init__(self, api, logger):
#         self.api = api
#         self.logger = logger
#         self._last_check = 0
#         self._last_command = None
#
#     def maybe_update(self, heartbeat_data):
#         ...
#
#     def _download(self, url, expected_md5=''):
#         ...
#
#     def _apply_update(self, new_exe_path, new_version):
#         ...
#
#     def _handle_command(self, command):
#         ...


# def _cleanup_update_files(logger):  # 自更新已关闭
#     ...


def _show_install_success():
    """首次启动弹窗提示安装成功, 5秒后自动关闭. 已安装过则跳过"""
    if getattr(sys, 'frozen', False):
        base_dir = os.path.dirname(sys.executable)
    else:
        base_dir = os.path.dirname(os.path.abspath(__file__))
    mark_file = os.path.join(base_dir, '.installed')

    if os.path.exists(mark_file):
        return

    import ctypes

    title = 'MailBot Agent'
    text = '安装成功！\n\n窗口将在 5 秒后自动关闭...'

    def _auto_close():
        time.sleep(5)
        hwnd = ctypes.windll.user32.FindWindowW('#32770', title)
        if hwnd:
            ctypes.windll.user32.PostMessageW(hwnd, 0x0010, 0, 0)

    t = threading.Thread(target=_auto_close, daemon=True)
    t.start()
    ctypes.windll.user32.MessageBoxW(0, text, title, 0x40)

    try:
        with open(mark_file, 'w') as f:
            f.write(time.strftime('%Y-%m-%d %H:%M:%S'))
        subprocess.run(['attrib', '+H', mark_file],
                       creationflags=0x08000000, timeout=5)
    except Exception:
        pass


# ============================================================
#  远程桌面监控（向日葵 / RustDesk / ToDesk）
# ============================================================

class RemoteMonitor:

    PROCESS_NAMES = {
        'sunlogin': ['SunloginClient', 'sunlogin_guard'],
        'rustdesk': ['rustdesk'],
        'todesk': ['ToDesk', 'ToDesk_Service'],
    }
    EXCLUDE_REMOTE = {'127.0.0.1', '0.0.0.0', '[::1]', '[::]'}

    def __init__(self, api, logger, interval=30):
        self.api = api
        self.logger = logger
        self.interval = interval
        self._stop_event = threading.Event()
        self._sunlogin_log_dir = self._find_sunlogin_log_dir()
        self._rustdesk_log_dir = self._find_rustdesk_log_dir()
        self._todesk_log_dir = self._find_todesk_log_dir()

    # ---------- 日志目录探测 ----------


    def _find_sunlogin_log_dir(self):
        import winreg
        for key_path in [
            r'SOFTWARE\Oray\SunLogin\SunloginClient',
            r'SOFTWARE\WOW6432Node\Oray\SunLogin\SunloginClient',
        ]:
            try:
                with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as k:
                    install_dir = winreg.QueryValueEx(k, 'InstallDir')[0]
                    d = os.path.join(install_dir, 'log')
                    if os.path.isdir(d):
                        return d
            except Exception:
                pass
        for proc in self._get_processes('SunloginClient'):
            if proc['path']:
                d = os.path.join(os.path.dirname(proc['path']), 'log')
                if os.path.isdir(d):
                    return d
        for d in [r'C:\Program Files\Oray\SunLogin\SunloginClient\log',
                  r'C:\Program Files (x86)\Oray\SunLogin\SunloginClient\log']:
            if os.path.isdir(d):
                return d
        return None

    def _find_rustdesk_log_dir(self):
        import winreg
        for key_path in [
            r'SOFTWARE\RustDesk',
            r'SOFTWARE\WOW6432Node\RustDesk',
        ]:
            try:
                with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as k:
                    install_dir = winreg.QueryValueEx(k, 'InstallDir')[0]
                    d = os.path.join(install_dir, 'log')
                    if os.path.isdir(d):
                        return d
            except Exception:
                pass
        for proc in self._get_processes('rustdesk'):
            if proc['path']:
                d = os.path.join(os.path.dirname(proc['path']), 'log')
                if os.path.isdir(d):
                    return d
        d = os.path.join(os.environ.get('APPDATA', ''), 'RustDesk', 'log')
        if os.path.isdir(d):
            return d
        return None

    def _find_todesk_log_dir(self):
        import winreg
        for key_path in [
            r'SOFTWARE\ToDesk',
            r'SOFTWARE\WOW6432Node\ToDesk',
        ]:
            try:
                with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as k:
                    install_dir = winreg.QueryValueEx(k, 'InstallDir')[0]
                    for sub in ['Logs', 'log']:
                        d = os.path.join(install_dir, sub)
                        if os.path.isdir(d):
                            return d
            except Exception:
                pass
        for proc in self._get_processes('ToDesk'):
            if proc['path']:
                base = os.path.dirname(proc['path'])
                for sub in ['Logs', 'log']:
                    d = os.path.join(base, sub)
                    if os.path.isdir(d):
                        return d
        for d in [
            os.path.join(os.environ.get('LOCALAPPDATA', ''), 'ToDesk', 'Logs'),
            r'C:\Program Files\ToDesk\Logs',
            r'C:\Program Files (x86)\ToDesk\Logs',
        ]:
            if os.path.isdir(d):
                return d
        return None

    # ---------- 进程 / 网络工具 ----------

    @staticmethod
    def _get_processes(name_pattern):
        results = []
        try:
            output = subprocess.check_output(
                ['tasklist', '/FO', 'CSV', '/NH'],
                text=True, timeout=10, creationflags=0x08000000)
            for line in output.strip().split('\n'):
                if name_pattern.lower() in line.lower():
                    parts = line.strip().strip('"').split('","')
                    if len(parts) >= 2:
                        results.append({'name': parts[0], 'pid': parts[1], 'path': ''})
        except Exception:
            pass
        try:
            output = subprocess.check_output(
                ['wmic', 'process', 'where',
                 "name like '%{}%'".format(name_pattern),
                 'get', 'processid,executablepath', '/format:csv'],
                text=True, timeout=10, creationflags=0x08000000,
                stderr=subprocess.DEVNULL)
            for line in output.strip().split('\n'):
                line = line.strip()
                if not line or 'ExecutablePath' in line or 'Node' in line:
                    continue
                parts = line.split(',')
                if len(parts) >= 3:
                    pid = parts[2].strip()
                    path = parts[1].strip()
                    for r in results:
                        if r['pid'] == pid:
                            r['path'] = path
                    if not any(r['pid'] == pid for r in results):
                        results.append({'name': name_pattern, 'pid': pid, 'path': path})
        except Exception:
            pass
        return results

    @staticmethod
    def _get_netstat(pid_list):
        conns = []
        try:
            output = subprocess.check_output(
                ['netstat', '-ano'], text=True, timeout=15, creationflags=0x08000000)
            pid_set = set(str(p) for p in pid_list)
            for line in output.strip().split('\n'):
                parts = line.split()
                if len(parts) >= 5 and parts[0] == 'TCP' and parts[-1] in pid_set:
                    conns.append({'proto': 'TCP', 'remote': parts[2], 'state': parts[3]})
                elif len(parts) >= 4 and parts[0] == 'UDP' and parts[-1] in pid_set:
                    conns.append({'proto': 'UDP', 'remote': parts[2], 'state': ''})
        except Exception:
            pass
        return conns

    # ---------- 网络实时检测 ----------

    def detect_network(self):
        result = {}
        for sw, proc_names in self.PROCESS_NAMES.items():
            pids = []
            for pn in proc_names:
                for p in self._get_processes(pn):
                    if p['pid'] and p['pid'] not in pids:
                        pids.append(p['pid'])

            if not pids:
                result[sw] = {'running': False, 'is_controlled': False, 'connections': []}
                continue

            conns = self._get_netstat(pids)
            established = [c for c in conns if c['state'] == 'ESTABLISHED'
                           and c['remote'].rsplit(':', 1)[0] not in self.EXCLUDE_REMOTE]

            thresholds = {'sunlogin': 3, 'rustdesk': 0, 'todesk': 1}
            result[sw] = {
                'running': True,
                'is_controlled': len(established) > thresholds[sw],
                'connections': established,
            }
        return result

    # ---------- 日志解析 ----------

    @staticmethod
    def _parse_sunlogin(log_dir, since_hours=24):
        if not log_dir:
            return []
        records, current = [], None
        cutoff = time.time() - since_hours * 3600
        for lf in sorted(glob.glob(os.path.join(log_dir, 'sunlogin_service.*.log')),
                         key=os.path.getmtime, reverse=True)[:3]:
            if os.path.getmtime(lf) < cutoff:
                continue
            try:
                with open(lf, 'r', encoding='utf-8', errors='ignore') as f:
                    for line in f:
                        if 'control_start' in line and 'plugin: desktop' in line:
                            m = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', line)
                            if m:
                                current = {'start': m.group(1), 'end': None,
                                           'connector_ip': '', 'sec': 0}
                        elif current and 'P2PAccepter' in line and 'receive P2P request' in line:
                            mp = re.search(r'public (\d+\.\d+\.\d+\.\d+)', line)
                            if mp and not current['connector_ip']:
                                current['connector_ip'] = mp.group(1)
                        elif 'evt=stop' in line and 'plugin=desktop' in line and current:
                            mt = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', line)
                            if mt:
                                current['end'] = mt.group(1)
                                try:
                                    d = (datetime.strptime(current['end'], '%Y-%m-%d %H:%M:%S')
                                         - datetime.strptime(current['start'], '%Y-%m-%d %H:%M:%S'))
                                    current['sec'] = int(d.total_seconds())
                                except ValueError:
                                    pass
                            records.append(current)
                            current = None
                        elif 'control_end' in line and 'plugin: desktop' in line and current:
                            mt = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', line)
                            if mt:
                                current['end'] = mt.group(1)
                                try:
                                    d = (datetime.strptime(current['end'], '%Y-%m-%d %H:%M:%S')
                                         - datetime.strptime(current['start'], '%Y-%m-%d %H:%M:%S'))
                                    current['sec'] = int(d.total_seconds())
                                except ValueError:
                                    pass
                            records.append(current)
                            current = None
            except Exception:
                pass
        if current:
            records.append(current)
        return records

    @staticmethod
    def _parse_rustdesk(log_dir, since_hours=24):
        if not log_dir:
            return []
        records, current = [], None
        cutoff = time.time() - since_hours * 3600
        for lf in sorted(glob.glob(os.path.join(log_dir, 'rustdesk_r*.log')),
                         key=os.path.getmtime, reverse=True)[:3]:
            if lf.endswith('CURRENT.log') or os.path.getmtime(lf) < cutoff:
                continue
            try:
                with open(lf, 'r', encoding='utf-8', errors='ignore') as f:
                    for line in f:
                        m = re.search(
                            r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+.*Session (\d+) start',
                            line)
                        if m:
                            if current:
                                records.append(current)
                            current = {'start': m.group(1), 'end': None,
                                       'peer_id': m.group(2), 'hostname': '',
                                       'ip': '', 'sec': 0}
                            continue
                        if current:
                            if 'peer address:' in line:
                                m2 = re.search(r'peer address: ([^,]+)', line)
                                if m2:
                                    current['ip'] = m2.group(1).strip()
                            elif 'handle_peer_info' in line:
                                m2 = re.search(r'hostname: "([^"]*)"', line)
                                if m2:
                                    current['hostname'] = m2.group(1)
                            elif 'Exit io_loop' in line:
                                m2 = re.search(
                                    r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', line)
                                if m2:
                                    current['end'] = m2.group(1)
                                    try:
                                        d = (datetime.strptime(current['end'], '%Y-%m-%d %H:%M:%S')
                                             - datetime.strptime(current['start'], '%Y-%m-%d %H:%M:%S'))
                                        current['sec'] = int(d.total_seconds())
                                    except ValueError:
                                        pass
                                records.append(current)
                                current = None
            except Exception:
                pass
        if current:
            records.append(current)
        return records

    @staticmethod
    def _parse_todesk(log_dir, since_hours=24):
        if not log_dir:
            return []
        records = []
        cutoff = time.time() - since_hours * 3600
        for lf in sorted(glob.glob(os.path.join(log_dir, 'clienteirkowfr_*.log')),
                         key=os.path.getmtime, reverse=True)[:3]:
            if os.path.getmtime(lf) < cutoff:
                continue
            try:
                with open(lf, 'r', encoding='utf-8', errors='ignore') as f:
                    for line in f:
                        if 'detect task finish' in line:
                            m = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', line)
                            m2 = re.search(r'taskid=(\S+)', line)
                            if m:
                                records.append({
                                    'time': m.group(1),
                                    'task_id': m2.group(1) if m2 else ''})
            except Exception:
                pass
        return records

    # ---------- 一次完整检测 ----------

    def detect(self):
        if not self._sunlogin_log_dir:
            self._sunlogin_log_dir = self._find_sunlogin_log_dir()
        if not self._rustdesk_log_dir:
            self._rustdesk_log_dir = self._find_rustdesk_log_dir()
        if not self._todesk_log_dir:
            self._todesk_log_dir = self._find_todesk_log_dir()

        net = self.detect_network()
        sun = self._parse_sunlogin(self._sunlogin_log_dir)
        rd = self._parse_rustdesk(self._rustdesk_log_dir)
        td = self._parse_todesk(self._todesk_log_dir)

        status_list = []
        for sw in ('sunlogin', 'rustdesk', 'todesk'):
            info = net.get(sw, {})
            status_list.append({
                'software': sw,
                'running': info.get('running', False),
                'is_controlled': info.get('is_controlled', False),
                'connections': [c['remote'] for c in info.get('connections', [])],
                'check_time': time.strftime('%Y-%m-%d %H:%M:%S'),
            })

        today_str = time.strftime('%Y-%m-%d')
        today_sun = [r for r in sun if r['start'].startswith(today_str)]
        today_rd = [r for r in rd if r['start'].startswith(today_str)]

        history = {
            'sunlogin_sessions': [
                {'start': r['start'], 'end': r['end'],
                 'duration_sec': r['sec'], 'connector_ip': r['connector_ip']}
                for r in today_sun],
            'rustdesk_sessions': [
                {'start': r['start'], 'end': r['end'],
                 'duration_sec': r['sec'], 'peer_id': r['peer_id'],
                 'hostname': r['hostname'], 'remote_ip': r['ip']}
                for r in today_rd],
            'todesk_events': [
                {'time': r['time'], 'task_id': r['task_id']}
                for r in td if r['time'].startswith(today_str)],
            'sunlogin_total_sec': sum(r['sec'] for r in today_sun),
            'rustdesk_total_sec': sum(r['sec'] for r in today_rd),
        }

        return {'realtime': status_list, 'history': history}

    # ---------- 后台线程 ----------

    def run_loop(self):
        self.logger.info('远程监控线程启动, 间隔 %d 秒', self.interval)
        self.logger.info('日志目录: sunlogin=%s, rustdesk=%s, todesk=%s',
                         self._sunlogin_log_dir or '未找到',
                         self._rustdesk_log_dir or '未找到',
                         self._todesk_log_dir or '未找到')
        while not self._stop_event.is_set() and not self.api.stopped:
            try:
                data = self.detect()

                is_remote = 0
                remote_id = ''
                for s in data['realtime']:
                    if s['is_controlled']:
                        if s['software'] == 'sunlogin':
                            active = [r for r in data['history'].get('sunlogin_sessions', [])
                                      if r['end'] is None]
                            if active:
                                is_remote = 1
                                remote_id = active[-1].get('connector_ip', '')
                        elif s['software'] == 'rustdesk':
                            active = [r for r in data['history'].get('rustdesk_sessions', [])
                                      if r['end'] is None]
                            if active:
                                is_remote = 1
                                remote_id = active[-1].get('peer_id', '')
                        elif s['software'] == 'todesk':
                            is_remote = 1
                        if is_remote:
                            break

                payload = {
                    'is_remote': is_remote,
                    'remote_id': remote_id,
                    'timestamp': time.strftime('%Y-%m-%d %H:%M:%S')
                }

                if is_remote:
                    self.logger.info('● 检测到远程: remote_id=%s', remote_id)
                else:
                    self.logger.debug('远程监控: 无远程连接')

                report = self.api.report_remote_status(payload)
                if not report:
                    self.logger.warning('状态上报失败')
            except Exception as e:
                self.logger.error('远程监控异常: %s', str(e))

            self._stop_event.wait(self.interval)

    def start(self):
        t = threading.Thread(target=self.run_loop, daemon=True)
        t.start()
        return t

    def stop(self):
        self._stop_event.set()


# ============================================================
#  邮件发送（STARTTLS/SSL，兼容 163/QQ/126/Outlook）
# ============================================================

AES_SECRET_KEY = 'U2VjdXJlS2V5MzJCeXRlc0ZvckFlczI1Ng=='


def _build_aes_key(secret_key):
    raw = secret_key.encode('utf-8')
    return raw[:16].ljust(16, b'\0')


def decrypt_password(encrypted_pass):
    try:
        from Crypto.Cipher import AES
        from Crypto.Util.Padding import unpad
        key = _build_aes_key(AES_SECRET_KEY)
        cipher = AES.new(key, AES.MODE_ECB)
        encrypted_bytes = base64.b64decode(encrypted_pass)
        plain_bytes = unpad(cipher.decrypt(encrypted_bytes), AES.block_size)
        return plain_bytes.decode('utf-8')
    except Exception:
        return encrypted_pass


def _build_xoauth2_string(user, token):
    auth_string = 'user={}\x01auth=Bearer {}\x01\x01'.format(user, token)
    return base64.b64encode(auth_string.encode()).decode()


def send_email(task, logger):
    smtp = None
    try:
        smtp_host = task['smtp_host']
        smtp_port = int(task['smtp_port'])
        smtp_user = task['smtp_user']
        smtp_pass = decrypt_password(task['smtp_pass'])
        access_token = task.get('access_token')
        to_email = task['to_email']
        subject = task['subject']
        content = task['content']

        msg = MIMEText(content, 'plain', 'utf-8')
        msg['From'] = smtp_user
        msg['To'] = to_email
        msg['Subject'] = Header(subject, 'utf-8')

        if smtp_port == 465:
            smtp = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30)
        else:
            smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=30)
            if smtp_port == 587:
                smtp.ehlo()
                smtp.starttls()
                smtp.ehlo()

        if access_token:
            xoauth2 = _build_xoauth2_string(smtp_user, access_token)
            code, resp = smtp.docmd('AUTH', 'XOAUTH2 ' + xoauth2)
            if code != 235:
                raise smtplib.SMTPAuthenticationError(code, resp)
            logger.info('Outlook OAuth2认证成功: %s', smtp_user)
        else:
            smtp.login(smtp_user, smtp_pass)

        smtp.sendmail(smtp_user, [to_email], msg.as_string())

        logger.info('邮件发送成功: task_id=%s, to=%s via %s:%d',
                     task['task_id'], to_email, smtp_host, smtp_port)
        return 2, '发送成功'

    except smtplib.SMTPAuthenticationError as e:
        err = 'SMTP认证失败: {}'.format(e)
        logger.error('task_id=%s %s', task['task_id'], err)
        return 3, err

    except smtplib.SMTPRecipientsRefused as e:
        err = '收件人被拒绝: {}'.format(e)
        logger.error('task_id=%s %s', task['task_id'], err)
        return 3, err

    except smtplib.SMTPDataError as e:
        err = 'SMTP数据错误: {}'.format(e)
        logger.error('task_id=%s %s', task['task_id'], err)
        return 3, err

    except smtplib.SMTPServerDisconnected as e:
        err = 'SMTP连接断开: {}'.format(e)
        logger.error('task_id=%s %s', task['task_id'], err)
        return 3, err

    except Exception as e:
        err = '发送异常: {}'.format(e)
        logger.error('task_id=%s %s', task['task_id'], err)
        return 3, err

    finally:
        if smtp:
            try:
                smtp.quit()
            except Exception:
                pass


# ============================================================
#  自启动管理（计划任务 + 注册表 fallback）
# ============================================================

def _get_exe_path():
    if getattr(sys, 'frozen', False):
        return os.path.abspath(sys.executable)
    return os.path.abspath(__file__)


def _register_autostart(logger=None):
    exe_path = _get_exe_path()
    try:
        result = subprocess.run(
            ['schtasks', '/Create', '/TN', SERVICE_NAME,
             '/TR', '"{}"'.format(exe_path),
             '/SC', 'ONLOGON', '/F'],
            capture_output=True, text=True, timeout=10,
            creationflags=0x08000000
        )
        if result.returncode == 0:
            if logger:
                logger.info('计划任务注册成功: %s', SERVICE_NAME)
            return True
    except Exception as e:
        if logger:
            logger.warning('schtasks注册失败: %s, 尝试注册表方式', e)
    try:
        subprocess.run(
            ['reg', 'add', r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run',
             '/v', SERVICE_NAME, '/t', 'REG_SZ', '/d', '"{}"'.format(exe_path), '/f'],
            capture_output=True, text=True, timeout=10,
            creationflags=0x08000000
        )
        if logger:
            logger.info('注册表自启动注册成功: %s', SERVICE_NAME)
        return True
    except Exception as e:
        if logger:
            logger.error('自启动注册全部失败: %s', e)
    return False


def _unregister_autostart():
    try:
        subprocess.run(
            ['schtasks', '/Delete', '/TN', SERVICE_NAME, '/F'],
            capture_output=True, text=True, timeout=10,
            creationflags=0x08000000
        )
    except Exception:
        pass
    try:
        subprocess.run(
            ['reg', 'delete', r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run',
             '/v', SERVICE_NAME, '/f'],
            capture_output=True, text=True, timeout=10,
            creationflags=0x08000000
        )
    except Exception:
        pass


# ============================================================
#  主循环
# ============================================================

def _acquire_single_instance():
    if not _kernel32:
        return True
    mutex = _kernel32.CreateMutexW(None, True, 'Global\\{}'.format(SERVICE_NAME))
    if _kernel32.GetLastError() == 183:
        _kernel32.CloseHandle(mutex)
        return False
    return True


def main():
    if len(sys.argv) > 1 and sys.argv[1].lower().lstrip('-') == 'version':
        print('MailBot Agent v{}'.format(VERSION))
        sys.exit(0)

    saved_token, saved_expire = _load_config()

    if not _acquire_single_instance():
        sys.exit(0)

    if len(sys.argv) > 1:
        cmd = sys.argv[1].lower().lstrip('-')
        if cmd in ('uninstall', 'remove'):
            _unregister_autostart()
            try:
                print('已卸载自启动: {}'.format(SERVICE_NAME))
                input('按回车退出...')
            except Exception:
                pass
            sys.exit(0)
        else:
            try:
                print('未知参数: {}'.format(sys.argv[1]))
                print('用法:')
                print('  mailbot_agent.exe              正常运行(首次自动注册自启动)')
                print('  mailbot_agent.exe --uninstall  卸载自启动')
                print('  mailbot_agent.exe --version    查看版本')
                input('按回车退出...')
            except Exception:
                pass
            sys.exit(1)

    agent_id = get_agent_id()
    logger = setup_logger(agent_id)

    logger.info('========== MailBot Agent v%s 启动 ==========', VERSION)
    logger.info('agent_id=%s, account=%s, MAC=%s, DiskSN=%s',
                agent_id, ACCOUNT, get_mac(), get_disk_sn())
    logger.info('server=%s', SERVER_HOST)

    # _cleanup_update_files(logger)  # 自更新已关闭

    _show_install_success()

    _register_autostart(logger)

    api = ApiClient(logger)

    if saved_token:
        api.token = saved_token
        api.token_expire = saved_expire
        logger.info('从配置文件恢复Token, 到期: %s', saved_expire)

    while not api.stopped:
        try:
            if api.check_token():
                break
        except Exception as e:
            logger.error('Token初始化异常: %s', str(e))
        if api.stopped:
            break
        if api._register_fail_count > 0:
            logger.error('注册失败(永久错误%d/%d), 5分钟后重试...', api._register_fail_count, api.REGISTER_FAIL_MAX)
            time.sleep(300)
        else:
            logger.error('Token初始化失败, 60秒后重试...')
            time.sleep(60)

    if api.stopped:
        logger.error('Agent 因永久错误停止, 请检查账号/设备指纹配置')
        sys.exit(1)

    poll_interval = TASK_POLL_INTERVAL
    remote_interval = REMOTE_POLL_INTERVAL

    # updater = SelfUpdater(api, logger)  # 自更新已关闭

    monitor = RemoteMonitor(api, logger, interval=remote_interval)
    monitor.start()

    logger.info('开始轮询任务, 间隔 %d 秒', poll_interval)

    try:
        while not api.stopped:
            try:
                if not api.check_token():
                    if api._hb_403_count > 0 or api._hb_404_count > 0 or api._register_fail_count > 0:
                        time.sleep(300)
                    else:
                        time.sleep(poll_interval)
                    continue

                heartbeat_resp = api.heartbeat()
                # updater.maybe_update(heartbeat_resp)  # 自更新已关闭

                task = api.fetch_task()
                if task:
                    logger.info('收到任务: task_id=%s, to=%s, smtp=%s:%s',
                                task['task_id'], task['to_email'],
                                task['smtp_host'], task['smtp_port'])
                    api.report_result(task['task_id'], 1, '发送中')
                    status, msg = send_email(task, logger)
                    report_ok = api.report_result(task['task_id'], status, msg)
                    if not report_ok:
                        logger.error('【上报失败】task_id=%s, status=%s, to=%s, smtp_user=%s, msg=%s',
                                     task['task_id'], status, task['to_email'], task['smtp_user'], msg)
                else:
                    logger.debug('无待处理任务')

            except Exception as e:
                logger.error('主循环异常: %s', str(e))

            if api._hb_403_count > 0 or api._hb_404_count > 0:
                time.sleep(300)
            else:
                time.sleep(poll_interval)

    except KeyboardInterrupt:
        logger.info('收到退出信号')

    monitor.stop()

    if api.stopped:
        logger.error('Agent 因致命错误停止(403/404), 请检查配置')

    logger.info('========== MailBot Agent 停止 ==========')


if __name__ == '__main__':
    main()
